Laboratoare PCom

Recomandări

Vă recomandăm să utilizați un IDE precum VS Code. Vom folosi Linux în cadrul laboratoarelor. Este recomandat ca înainte de orice laborator să parcurgeți lectura indicată. Laboratoarele și temele pot fi făcute în C sau C++. Laboratoarele pot fi navigate folosind săgețile de pe tastatură → ←.

Contribuții laborator

Oricine poate contribui pentru a îmbunătăți laboratoarele. Scheletul laboratoarelor se găsește pe Gitlab. Textul laboratoarelor îl găsiti în acest repo. În partea de sus dreaptă a fiecărei pagini există un buton de edit pe care îl puteți folosi pentru a sugera modificări.

Lectură laborator


De parcurs înainte de laborator:


Materiale video opționale:

Despre laboratoarele de PCom

Laboratoarele de PCom presupun cunoștințe despre USO, PC, SD și SO. În aceste laboratoare vom implementa și utiliza protocoalele de comunicație. Un protocol de comunicație este un set de reguli bine definite pe care interlocutorii trebuie să le urmeze în timpul comunicatiei.

Urmatorul slideshow prezinta ceea ce vom face la laboratoarele de PCom:

Înainte de fiecare laborator, vă recomandăm să parcurgeți lectură recomandată în prima parte a laboratorului. În cazul de față, video-ul intitulat Sending Digital Information over a Wire (durata de 4 minute). În general, timpul necesar pentru a studia lectură recomandată este de câteva minute.

Exercițiile din laboratoare pot fi rezolvate atât în C cât și C++, cu toate aceste recomandăm implementări în versiuni moderne de C++. Recomandăm Visual Studio Code cu extensia de C/C++ pentru laboratoare și teme. Atât temele cât și laboratoarele se vor face pe Linux.

Nivelul fizic

Nivelul fizic se referă la protocoalele și tehnicile utilizate pentru a permite schimbul de informații. Schimbul de informații se face peste un mediu de transmisie (link).

Exemple de medii de transmisie:

  • wireless
  • cablu electric
  • fibră optică
  • semnale de fum

În cazul comunicației prin cablu, nivelul fizic se ocupă cu codificarea biților în semnale electrice. Un exemplu de codificare este următoarea:

Transmițător (Sender):

  • la fiecare milisecundă cablul electric va fi conectat la 5V pentru a transmite bitul 1 și la 0V pentru a transmite bitul 0.

Receptor (Receiver):

  • la fiecare milisecundă va măsura tensiunea de pe fir


Rata de transmisie (bit rate) reprezintă numărul de biți transmiși pe secundă. În exemplul cu cablul electric, rata de transmisie este de 1000 de biți pe secundă.


Internetul

Pe baza celor discutate în secțiunea precedentă, pe parcursul laboratorului vom pune bazele internetului pe care cu toții îl folosim astăzi.

La începutul anilor 1970 internetul se rezumă la comunicarea peste un cablu între două dispozitive printr-un protocol simplu, dar în doar câțiva ani complexitatea a crescut enorm. În figura de mai jos vedem precursorul internetului de astăzi, ARPANET.

Pentru a modela cât mai ușor arhitectura Internetului, cercetătorii de la acea vreme au propus diferite modele de referință. Dintre acestea, Open Systems Interconnection (OSI), modelul propus de Huber Zimmerman, a fost cel mai influent. Totuși, în practică, modelul dominant de referință folosit este TCP/IP. În figura de mai jos, putem vedea cele două modele de referință și o serie de protocoale la fiecare nivel. La laborator, vom avea o abordare bottom-up. Vom porni de la protocoale simple pentru a conecta două calculatoare și vom ajunge să implementăm protocoale de nivel aplicație, precum HTTP, folosit astăzi mai ales de web browsers. În general, protocoalele sunt descrise în documente numite Request for Comment (RFC). De exemplu, RFC 791 descrie protocolul IP.

Networking de mână

Accesarea unei pagini Web

1. În orice browser, accesați website-ul http://example.com/

2. Vom realiza același lucru de mână:

  • Din CLI, rulați telnet example.com 80. Această comandă deschide o conexiune (flux de octeți) către un alt calculator, example.com, care funcționează ca un server și rulează un serviciu HTTP (Hypertext Transfer Protocol), folosit de World Wide Web. Dacă totul decurge conform planului, veți vedea mesajul Connected to example.com..
  • Scrieți GET / HTTP/1.1. Aceasta reprezintă acțiunea dorită (GET), calea către resursa pe care dorim să o accesăm (/) și versiunea de protocol folosită (HTTP/1.1)
  • Scrieți Host: example.com - Specifică gazda, de la care dorim să accesăm resursa.
  • Scrieți Connection: close - Specifică serverului că nu vom mai trimite cereri după aceasta.
  • Apăsați Enter. Acesta trimite o linie goală care reprezintă faptul că am terminat cu cererea HTTP.
  • Dacă totul decurge conform planului, veți vedea un răspuns similar cu cel pe care l-ați văzut într-un browser.

La finalul cursului de PCom, veți înțelege ce s-a întâmplat în spatele acestor comenzi.

Server simplu

Am văzut că telnet poate acționa ca un client care se conectează la un server. Acum vom vedea cum implementam propriul nostru server.

  • Într-un terminal vom rula netcat -v -l -p 9090 pentru a porni un server simplu care ascultă pe portul 9090.
  • În alt terminal, vom rula telnet localhost 9090 pentru a ne conecta local, pe calculator, la serverul care rulează pe portul 9090.
  • Dacă totul decurge conform planului, vom vedea în al doilea terminal următorul mesaj: Connection from localhost 53500 received!.
  • În oricare dintre ferestre, putem scrie orice, și după ce apăsăm "Enter", acesta ajunge și în partea cealaltă.

Observăm astfel că avem mai multe procese care pot implementa funcționalități de networking.

Monitorizarea traficului

Pentru o mai bună înțelegere a protocoalelor pe care le folosim și pe care urmează să le dezvoltăm, vom folosi două aplicații pentru a captura traficul și a îl analiza: Wireshark și tcpdump. Aceste două unelte vor fi esențiale pe parcursul materiei.

Monitorizarea traficului

Wireshark

Wireshark este o aplicație folosită pentru analiza traficului de rețea de pe un dispozitiv. Acesta se găsește în majoritatea repository-urilor și poate fi instalat astfel:

sudo apt install wireshark

Pe Linux întâlnim conceptul de network interface. De exemplu eth0 este interfață pentru Ethernet (cablu), iar wlp0s20f3 este de wireless. Pentru a putea intercepta traficul pe o astfel de interfață trebuie să rulăm ca root.

sudo wireshark

Wireshark are o interfață simplă -- pe fereastra ce se deschide vor apărea interfețele disponibile. Pe calculatorul pe care a fost făcut screenshot-ul, apar mai multe interfețe precum wlp0s20f3, vmnet, etc.

Pentru a începe capturarea traficului de pe o interfață, trebuie să selectăm interfața, după care să dăm click pe butonul Capture din stânga sus.

Pachetele capturate apar în prima fereastră. Aici putem vedea diverse informații, precum adresa sursă, protocolul, timpul la care a fost capurat pachetul. Dacă dăm click pe un pachet, în fereastră de la mijloc vor apărea toate informațiile despre acesta. În cazul de față, vedem un pachet compus de trei protocoale: Ethernet, Internet Protocol 4 (IPv4) și Transmission Control Protocol (TCP). În fereastră, am extins doar conținutul TCP al pachetului. Fereastră de jos indică conținutul pachetului sub formă de octeți.

Bară de filtrare. Wireshark permite filtrarea pachetelor: în bara unde apare Apply a display filter putem adaugă filtre precum:

// Doar pachete ce au adresa IP sursa sau destinatia 192.0.2.1 ip.addr == 192.0.2.1 // Doar pachete tcp sau udp ce folosesc portul 80 // In laboratorul 6 vom discuta despre porturi tcp.port == 80 || udp.port == 80

tcpdump

Tcpdump este un utilitar de Linux care permite interceptarea și afișarea traficului de rețea direct din terminal.

❯ sudo tcpdump -i wlp0s20f3 dropped privs to tcpdump tcpdump: verbose output suppressed, use -v[v]... for full protocol decode listening on wlp0s20f3, link-type EN10MB (Ethernet), snapshot length 262144 bytes 00:16:49.089497 IP6 thinky.41894 > sof02s17-în-x0a.1e100.net.https: Flags [P.], seq 2546843426:2546843465, ack 2356704842, win 501, options [nop,nop,TS val 943111274 ecr 2415067731], length 39 00:16:49.136836 IP6 sof02s17-în-x0a.1e100.net.https > thinky.41894: Flags [.], ack 39, win 289, options [nop,nop,TS val 2415125795 ecr 943111274], length 0

Note

Pentru afla interfețele disponibile pe o mașină ce rulează Linux putem folosi următoarea comandă:

ip address show

tcpdump permite utilizarea filtrelor precum:

# Afișează doar pachete ce au ca IP sursă sau destinație 8.8.8.8 tcpdump host 8.8.8.8 # Afișează doar pachete ce au ca port destinație 80 tcpdump src port 80

Tcpdump poate salva traficul interceptat într-un fișier .pcap ce poate fi deschis cu Wireshark.

tcpdump -w traffic.pcap

Noțiuni generale de programare

Vă recomandăm următorul crash course de C pentru a vă reaminti concepte precum structurile, pointerii și alocarea de memorie.

Dacă alegeți să lucrați în C++, vă recomandăm acest ghid despre cum să scrieți cod mai simplu, mai eficient și mai ușor de întreținut.

Compilare

Cel mai popular compilator de C/C++ este gcc. Să presupunem că avem un fișier sursă numit hello.c. Compilarea standard a acestui fișier se face folosind gcc hello.c.

Ca rezultat se obține un fișier a.out care se poate executa. Dacă însă compilăm folosind gcc hello.c -o hello se va obține fișierul executabil hello.

Pentru un cod scris in C++, .cpp, vom folosi g++.

  • Opțiunea -g va permite obținerea unui executabil care va conține informații de debug. Această opțiune este utilă în combinație cu un program de debugging cum ar fi gdb.

NOTĂ

Pentru a detecta accesările invalde la memorie, vom compila programul nostru cu address sanitization (ASAN) folosind următorul flag: -fsanitize=address. Mai mult, pentru a evita undefined behavior vom folosi -fsanitize=undefined. Aceste opțiuni sunt utile pentru a ne asigura că programul scris de noi nu are bug-uri ascunse. Recomandăm rularea codului cu aceste opțiuni înainte de a face submisii la teme.


Debugging

Cum networking-ul deseori înseamnă programare low level, ne vom lovi de probleme precum Segmentation Fault. În cazul în care nu putem identifica problema folosind valgrind, printf debugging, sau opțiunile de sanitizare, va trebui să folosim un debugger.

Va recomandăm articolul Debugging pentru a va reaminti de cum folosim GDB atât din CLI cât și prin intermediul unui IDE.

File descriptors

În a doua jumătate a laboratorului vom folosi API-ul de POSIX sockets și noțiunea de file descriptor (fd). Simplist, un file descriptor este un număr întreg ce reprezintă un identificator în tabela de fișiere a unui program. De exemplu, dacă deschidem un fișier, identificatorul ar putea fi numărul 4. Fiecare proces are propria sa tabelă de file descriptors.


Note

Un program are inițial 3 file descriptors: 0 - stdin, 1 - stdout si 2 - stderr. În general, pentru primul fisier pe care il deschidem cu open, o sa primim ca file descriptor valoarea 3.


Exemplu

Avem un exemplu de folosire a funcțiilor de acces la fișiere în programul de mai jos, care copiază fișierul ”sursă” în fișierul ”destinație”. Urmăriți comentările pentru a înțelege mai bine funcționalitatea.

#include <unistd.h> /* pentru open(), exit() */ #include <fcntl.h> /* O_RDWR */ #include <stdio.h> /* perror() */ #include <errno.h> #include <stdlib.h> void fatal(char * mesaj_eroare) { perror(mesaj_eroare); exit(0); } int main(void) { int source_fd, dest_fd; int bytes_count; char buf[1024]; /* Open primeste ca argumente path-ul catre un fisier si un flag, in cazul acesta flag-ul speicica ca urmeaza doar sa citim din fisier. */ source_fd = open("/path/to/source", O_RDONLY); /* O_CREAT este un flag special care specifica faptul ca daca fisierul nu exista, vom creea unul noi cu permisiunile 0644 */ dest_fd = open("/path/to/destination", O_WRONLY | O_CREAT, 0644); /* Daca open returneaza o valoare mai mica de 0, atunci inseamna ca avem o eroare */ if (source_fd < 0 || dest_fd < 0) fatal("Nu pot deschide un fisier"); /* Fisierele sunt acuma identificate prin cele 2 file descriptors, source_fd si dest_fd */ /* Cu ajutorul functie read citim din fisier, in cazul de fata citim din fisierul identificat prin source_fd date si le punem in buf. Citim maxim sizeof(buf), adica 1024 */ while ((bytes_count = read(source_fd, buf, sizeof(buf)))) { /* read returneaza numarul de bytes cititi */ if (bytes_count < 0) fatal("Eroare la citire"); /* read muta si ceea ce numim cursorul, daca de exemplu fisierul are 2048 de bytes, primul apel de read va muta cursorul pe pozitia 1024, o citire ulterioara va returna date de la pozitia 1024 in sus. lseek este o functie speciala pentru a interactiona cu cursorul */ /* write este similar cu read */ bytes_count = write(dest_fd, buf, bytes_count); if (bytes_count < 0) fatal("Eroare la scriere"); } /* Este good practice sa eliberam file descriptors */ close(source_fd); close(dest_fd); return 0; }

NOTĂ

O bună practică pe care o recomandăm este să verificați valoarea de retur pentru orice apel de funcție și să afișați mesaje corespunzătoare de eroare.


Exerciții


1. Vrem să ne pregătim pentru a trimite date în format binar peste un mediu de transmisie. În fișierul de aici avem un array de structuri de tipul Packet în format binar. Realizați un program în C/C++ care să citească array-ul cu elemente de tip Packet din acest fișier și să afișeze conținutul din payload al fiecărei intrări. Procesul prin care acest fișier a fost creat se numește serializare și îl vom întâlni atunci când vom serializa datele pentru a fi trimise spre rețea. Procesul invers, pe care îl veți implementa, se numește deserializare.

struct Packet { char payload[100]; int sum; int size; };

2. Vom folosi telnet -4 telehack.com pentru a ne conecta la un server TCP. In prompt o să scriem starwars si enter. Acest server trimite un șir de biți către noi, iar telnet îl afișează pe ecran (stdout). Dacă totul a mers bine, ar trebui să vedeți prima scenă din Star Wars în format ASCII.

3. Folosind Wireshark vom analiză traficul generat de către comanda telnet de la exercițiul precedent. Identificați un pachet ce conține bucăți din textul ce apare în terminal.


Note

În exemplele de filtre pe care le-am văzut până acuma erau utilizate doar adrese IP (e.g. ip.addr == 127.0.0.1). Pentru a afla adresa ip a telehack.com vom folosi host telehack.com.


4. Pentru a ne obișnui cu programarea low level, scrieți un utilitar similar cu cat în C/C++. Acesta trebuie să afișeze conținutul unui fișier, linie cu linie, la stdout. Vom folosi API-ul direct peste file descriptors (e.g. read, open).

Lectură laborator


De parcurs înainte de laborator:


Materiale video opționale:

Framing

În general, nu suntem interesați în a lucra cu date la nivel de biți. Aplicațiile pe care le dezvoltăm lucrează cu mesaje, structuri sau fișiere complete. Nivelul fizic ne permite să transmitem un flux de biți de la un dispozitiv la altul, dar datele pe care le transmitem sunt structurate în blocuri la nivel logic.

Receptorul trebuie să știe să delimiteze între aceste blocuri pentru a extrage datele corect. Cum nivelul fizic nu este ideal, pot apărea probleme precum desincronizări, astfel că soluția naivă în care spunem că fiecare 8 biți reprezintă un frame nu este valabilă.

010|01000001|01000010|10101 'A' 'B'

Unitatea de informație pe care o vom folosi la nivelul DataLink este cadrul (frame) și reprezintă fluxul de biți care constituie un bloc logic de date.


Note

Problema pe care încercăm să o rezolvăm este: Cum face expeditorul (sender) codificarea cadrelor (frames) astfel încât receptorul (receiver) să le poată extrage eficient din fluxul de biți pe care îl primește de la nivelul fizic?


Bit stuffing

O posibilă metodă de framing o reprezintă bit stuffing. Vom folosi 01111110 ca și delimitator de cadre.

De exemplu, dacă vrem să trimitem 0100 atunci o să îl codăm ca și 01111110|0100|01111110. Receptorul, doar după ce a primit 0111110 va începe să citească conținutul cadrului.

Ce facem în cazul în care vrem să trimitem 6 biți de 1, 111111? Regula este simplă, după fiecare 11111, se inserează un 0. Astfel, delimitatorul 01111110 nu o să apară niciodată în conținutul unui cadru.

Sender 111111 -> 1111101 Receiver 1111101 -> 111111 1111100 -> 111110

Putem dezvolta astfel un protocol foarte simplu de nivel 2. Specificatia acestui protocol (un fel de RFC) conține structura cadrului și regula definită pentru a nu întâlnii delimitatorul în datele pe care le vom transmite (payload).

DELIM|PAYLOAD|DELIM

Character stuffing în practică

Cum în software ne este mult mai ușor să lucrăm la nivel de byte decât bit, nivelul fizic ne oferă și un serviciu de trimitere de fluxuri de bytes. În mod similar cu bit stuffing, vom folosi mai multe caractere speciale pentru a ne delimita frame-ul. Vom folosi DLE, STX și ETX definiți în tabela ASCII

A B C => DLE STX A B C DLE ETX

A B C DLE STX D => DLE STX A B C DLE DLE STX D DLE ETX

Mai jos avem o diagramă care surprinde transmisia de date folosind framing. Vedem cum la nivelul DataLink folosind protocolul nostru simplu cu bytes de separare putem oferi un serviciu de trimitere de frames.

Următorul exemplu prezintă o posibilă implementare de character stuffing folosind DLE,STX și ETX. Presupunem că am cumpărat o placă de rețea (NIC) care are în firmware două funcții send_byte și recv_byte. În general, implementarea unui protocol se face într-o bibliotecă pe care atât programul ce rulează la transmițător cât și cel de la receptor o folosesc.

Transmiterea este relativ simplă.

/* Transmitem date aflate în buffer */ /* Trimite delimitator */ send_byte(DLE); send_byte(STX); /* Trimite bytes din frame */ for (int i = 0; i < size; i++) { /* Facem escape la escape */ if (buffer[i] == DLE) send_byte(DLE); send_byte(buffer[i]); } /* Trimite delimitator final */ send_byte(DLE); send_byte(ETX);

Recepția cadrului are o complexitate mai mare, deoarce în exemplul nostru recv_byte întoarce date aleatoare când transmițătorul nu trimite nimic.

char c1, c2; c1 = recv_byte(); c2 = recv_byte(); /* Cât timp nu am primit DLE STX citim bytes. Atenție la modul în care salvăm * byte-ul precedent. */ while( ((c1 != DLE) && (c2 != STX)) || (c1 == DLE && c2 != STX) \ || (c1 != DLE && c2 == STX)) { c1 = c2; c2 = recv_byte(); } /* Am primit începutul unui frame: DLE STX */ for (int i = 0; i < max_size; i++) { char byte = recv_byte(); /* Dacă am primit un escape */ if (byte == DLE) { byte = recv_byte(); /* Am primit DLE ETX */ if (byte == ETX) return i; /* După DLE, trebuie să primim alt DLE, altfel frame-ul nu este bine structurat */ else if (byte != DLE) return -1; } /* Punem în buffer conținutul frame-ului */ buffer[i] = byte; }

Tipuri de comunicatie

În funcție de mediu, putem avea un receptor sau mai mulți. Comunicarea poate fi clasificată astfel în: Point-to-Point și Point-to-Multipoint.

Point-to-Point

Comunicarea Point-to-Point se întâmplă atunci când avem doar două dispozitive. În acest caz, dispozitivele nu trebuie să specifice cui vor să trimită frame-urile.

În harta de mai jos, putem vedea legăturile Point-to-Point dintre mai multe dispozitive de la acea vreme (Decembrie 1970): de exemplu, STANFORD-UTAH.

Exemple de protocoale de nivel 2 dezvoltate pentru comunicare Point-to-Point: Point-to-Point Protocol (PPP), High-Level Data Link Control (HDLC)*.

Point-to-Multipoint

Într-o transmisie de tip Point-to-Multipoint, avem un transmitator și mai mulți receptor. Cel mai popular mod de a identifica destinația este de a include un câmp de identificare în antetul protocolului (de exemplu, adresa MAC în Ethernet). În imaginea de mai jos sunt două exemple de comunicații multipoint.

Metrici

Pentru a putea studia performanța unui protocol de nivel DataLink, ne interesează următoarele metrici:

  • Bandwidth - se măsoară în biți / secunda și reprezinta cantitatea de informație care poate fi transmisa într-o unitate de timp pe legătura de date
  • Delay - se măsoară în secunde și reprezinta timpul care le ia unor date trimise printr-un mediu să ajungă la destinație
  • Bandwidth delay product (BDP) - reprezinta numarul total de biti ce se pot afla pe un link la un anumit moment de timp

Legătura de date poate fi asemănata cu un cilindru în care datele sunt introduse de către transmițător și primite de către receptor. Aria secțiunii cilindrului reprezinta viteza de transmisie, iar înălțimea este timpul de propagare. Volumul cilindrului determina cantitatea de informație aflată pe legătura de date, la un anumit moment de timp. Deci, cantitatea de informație aflata pe link la un anumit moment de timp este: Bandwidth × Delay.

Tabelul de mai jos prezinta mai multe metrici pentru link-uri existente.

Tip LinkBandwidthOne-Way DistanceDelayBandwidth x Delay (BDP)
Wireless LAN54 Mbps50 m0.15 us18 bits
Satellite1 Gbps35000 km115 ms230 Mb
Cross-country fiber10 Gbps4000 km40 ms400 Mb

Exerciții

În cele ce urmează vom punele bazele unui mic protocol de nivel DataLink peste un mediu fizic ideal (fără pierderi/coruperi).

Scheletul laboratorului se găsește aici, tot acolo se găsește și un README.md cu funcționalitatea disponibilă.

1. Vrem să implementăm un protocol de nivel DataLink care folosește tehnică byte stuffing pentru a trimite șiruri de caractere ca payload. La acest exercițiu o să presupunem o transmisie de tip Point-to-Point, așa că nu trebuie să specificăm destinația în protocol. Pentru simplitate, payload-ul va avea mereu 30 de bytes și nu vom include un câmp size în header. Frame-ul îl putem structura astfel:

/* Atributul este folosit pentru a anunță compilatorul să nu alinieze structura */ /* DELIM | DATE | DELIM */ struct __attribute__((packed)) Frame { char frame_delim_start[2]; /* DEL STX */ char payload[30]; /* Datele pe care vrem să le transmitem */ char frame_delim_end[2]; /* DEL ETX */ };

NOTE

Executați scriptul ./run_experiment.sh pentru a observă un demo al funcționalitaților disponibile. Acesta pornește un simulator de nivel fizic și rulează ./recv și ./send.

Un șir de caractere precum char buffer[1024] reprezintă un șir de bytes, astfel putem face operații precum ((struct Frame) buffer)->frame_delim_start[0], memcpy(buffer, &frame, sizeof(struct Frame)) sau send_byte(((char *) frame));


2. Acum că am reușit să trimitem primele noastre frame-uri care conțin șiruri de caractere, vrem să extindem protocolul pentru a funcționa într-un mediu de transmisie Point-to-Multipoint. Avem mai multe dispozitive legate la același cablu (de exemplu, dispozitivele care măsoară tensiunea de pe cablu) și fiecare este identificat printr-un întreg.

Extindem protocolul nostru prin adăugarea a două noi câmpuri, sursă și destinație. Structura de date de mai jos reprezintă conținutul frame-ului pe care vrem să-l transmitem. Adaptați atât implementarea protocolului din sender cât și din receiver pentru a funcționa peste noile constrângeri.

/* Atributul este folosit pentru a anunță compilatorul să nu alinieze structura */ /* DELIM | SOURCE | DEST | PAYLOAD | DELIM */ struct __attribute__((packed)) Frame { char frame_delim_start[2]; /* DEL STX */ int source; /* Identificator SURSĂ */ int dest; /* Identificator DESTINAȚIE */ char payload[30]; /* Datele pe care vrem să le transmitem */ char frame_delim_end[2]; /* DEL ETX */ };

În simulatorul nostru avem o conexiune PPP, la acest exercițiu doar vom verifica în receiver câmpul destinație. Protocolul Point-to-Point Protocol face ceva similar, are în header un câmp address ce nu este folosit :).

3. Vrem să măsurăm delay-ul pentru a transmite un frame de 100 bytes și unul de 300. În sender vom trimite un frame ce conține un timestamp, în receiver vom măsura latența (diferența de timp între timestamp și timpul curent). Ce observăm? (Puteți folosi orice metodă de măsurare a timpului, precum cele prezente în articolul următor.

Lectură laborator


De parcurs înainte de laborator:


Materiale video optionate:

Detectarea erorilor de transmisie

În timpul transmiterii de date, pot apărea erori. Acestea se pot manifesta ca biți ale căror valori sunt schimbate într-un cadru. Întâlnim două tipuri de erori la nivelul legăturii de date:

  • cadrele pot fi corupte
  • cadrele pot fi pierdute sau pot apărea cadre neașteptate

De exemplu, dacă trimitem șirul de biți 11111111 prin intermediul unui cablu, din cauza interferențelor electromagnetice, ultimul bit ar putea avea valoarea schimbată și receptorul ar primi 11111110.



În general, pentru a putea transmite date peste link imperfect, vom folosi una dintre următoarele abordări:

  • detectarea erorilor și retransmisia (e.g. CRC, Checksums)
  • corectarea erorilor (e.g. Hamming)

În acest caz, apare o decizie de proiectare în dezvoltarea protocolului. De exemplu, dacă stim ca transmisia se intampla peste un mediu cu latență mare și rată mare de corupere a datelor, cum ar fi comunicarea între Pământ și Marte (~20 de minute), ar avea sens să folosim o metodă de corectare a erorilor. În schimb, dacă latența este mică, ar fi mult mai optim să realizăm o retransmisie. Ethernet folosește un câmp de CRC pentru detectarea erorilor.

Sume de control (checksum)

Adesea, atunci când transmitem date peste un link, este necesar ca receptorul să determine dacă cadrul primit a fost corupt. Pentru a face acest lucru, transmițătorul va include un nou câmp (numit checksum) în protocol, care este rezultatul aplicării unei funcții pe conținutului cadrului. Receptorul poate recalcula această valoare cu datele din cadrul pe care acesta le-a primit și detecta cadrele corupte în cazul în care aceste două valori diferă.

Un exemplu simplu funcție de checksum este suma tuturor octetilor din cadru mod 256. Mai jos găsiți o astfel de implementare.

uint8_t compute_checksum(const char *buff, size_t count) { /* Ca input primim un buffer char *buf de dimensiune int count */ uint32_t sum = 0; uint8_t checksum /* Adăugăm în sum fiecare byte din buffer */ while (count > 0) { sum += *((uint8_t *) buff) buf += 1; count -= 1; } checksum = sum % 256; return checksum; }

Ce facem dacă am detectat o erorare? De cele mai multe ori, la detecția unei erori se va face o retransmisie de către protocolul de nivel superior (e.g. TCP la nivel transport).

O problemă a algoritmilor de checksum este simplitatea acestora ce poate cauza coliziuni.

Cadru : 6 23 4 Cadru cu checksum : 6 23 4 33 (6 + 23 + 4 = 33 % mod 256 = 33) Cadru la receptor : 8 20 5 33 (8 + 20 + 5 = 33 % mod 256 = 33)

În acest exemplu, chiar dacă conținutul mesajului s-a schimbat, checksum-ul calculat a fost același, existând o șansă de 1/256 (256 - de la operatorul de modulo) ca o eroare să nu fie detectată. Pentru a rezolva această problemă, s-a ales folosirea unor algoritmi precum Cyclic Redundancy Codes (CRC).


Note

Termenul de checksum a fost folosit inițial pentru a descrie algoritmi de tipul sume, dar în ziua de azi curpinde și algoritmi mai sofisticați precum CRC.


Endianness

În funcție de ordinea în care un șir de octeti este stocat în memorie avem două interpretări: Little Endian și Big Endian. Reprezentarea cu care suntem cel mai bine obișnuiți este Big Endian, așa cum reprezentăm datele pe foaie, cel mai semnificativ byte este primul. În imaginea de mai jos avem un exemplu de cum un int pe 32 de biți, 0x01020304 poate avea valori diferite în funcție de cum este interpretat. De notat faptul că endianness-ul este același pentru un char (1 byte)

În general, procesoarele moderne folosesc Little Endian. Totuși, plăcile de rețea, folosesc Big Endian. O să întâlnim denumirea Network Order (Big Endian) și Host Order (Little Endian).

În API-ul POSIX avem mai multe funcții care se pot folosi pentru a face trecerea Host Order <-> Network Order:

#include <arpa/inet.h> // host to network long uint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort); // network to host long uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort)

Cyclic Redundancy Codes (CRCs)

Dacă reprezentăm datele transmise ca pe un număr, atunci restul împărțirii este valoarea pe care o putem introduce în header, iar receptorul poate verifica dacă datele primite au același rest.

De exemplu, atât transmițătorul cât și receptorul sunt de acord să folosească 13131 că și împărțitor. REST = 123131(PAYLOAD) % 13131 = 200 |DELIM|PAYLOAD|REST|DELIM|

În practică, nu folosim numere ci polinoame, printre altele fiind mult mai ușor de lucrat cu ele (nu o să avem carry). Cyclic Redundancy Codes (CRC) reprezintă restul împărțirii polinomiale modulo 2 a datelor pe care vrem să le trimitem. Putem vedea payload-ul ca și reprezentarea unui polinom.

PAYLOAD= 'H' 'i' '!' 01001000 01101001 00100001

Cu reprezentarea matematică

x22+x19+x14+x13+x11+x8+x5+x0

De ce modulo 2 (inelul claselor de resturi modulo 2)? Deoarce vrem ca indicii în urma calculelor să fie 1 sau 0, altfel, atunci când facem împărțirea, am ajunge la valori reale, iar noi, putem folosi doar valori binare.

Pentru optimizări, operațiile în acest inel sunt echivalente cu XOR (e.g. 1 + 1 = 0). În funcție de polinomul la care o să ne raportăm, avem diferite implementări de CRC. Aici gășiți mai multe exemple de polinoame si la ce sunt folosite. CRC 32 folosește următorul polinom:

x32+x26+x23+x22+x16+x12+x11+x10+x8+x7+x5+x4+x2+x+1

cu reprezentarea in binar si hexa:

0xEDB88320 11101101-10111000_10000011_00100000 x^0

Cum avem doar 32 de biți, nu ne interesează indicele lui x^32. Polinomul a fost ales astfel încât să funcționeze bine în cazul erorilor în rafală.

Pentru string-ul "123456789", valoarea CRC32 este 0xCBF43926.

Un exemplu de imartire de polinoame modulo 2 este acesta:

10011 ) 11010110110000 = Bits of payload =Poly 10011,,.,,.... -----,,.,,.... 10011,.,,.... (operatia de xor cand primul bit e 1) 10011,.,,.... -----,.,,.... 00001.,,.... (cand primul bit e zero, doar face un shift stanga 00000.,,.... pentru a lua urmatorul indice de exponent) -----.,,.... 00010,,.... 00000,,.... -----,,.... 00101,.... 00000,.... -----,.... 01011.... 00000.... -----.... 10110... 10011... -----... 01010.. 00000.. -----.. 10100. 10011. -----. 01110 00000 ----- 1110 = Remainder = The CRC!

O posibilă implementare a algoritmului CRC32 este următoarea:

uint32_t compute_crc32(const char *buffer, size_t len) { /* unsigned char *buffer contine payload-ul, len este lungimea acestuia */ /* Prin conventie crc-ul initial are toti bitii setati pe 1 */ uint32_t crc = ~0; // 0xffffffff const uint32_t POLY = 0xEDB88320; /* Parcurgem fiecare byte din buffer */ while(len--) { /* crc contine restul impartirii la fiecare etapa */ /* nu ne intereseaza catul */ /* adunam urmatorii 8 bytes din buffer */ crc = crc ^ *buffer++; for( int bit = 0; bit < 8; bit++ ) { /* 10011 ) 11010110110000 = Bytes of payload =Poly 10011,,.,,.... -----,,.,,.... 10011,.,,.... (operatia de xor cand primul bit e 1) 10011,.,,.... -----,.,,.... 00001.,,.... (asta e noua valoare a lui crc) (crc >> 1) ^ POLY */ if( crc & 1 ) crc = (crc >> 1) ^ POLY; else /* 10011 ) 11010110110000 = Bytes of payload =Poly 10011,,.,,.... -----,,.,,.... 10011,.,,.... 10011,.,,.... -----,.,,.... 00001.,,.... primul bit e 0, 00000.,,.... -----.,,.... 00010,,.... am facut shift la dreapta, pentru ca suntem pe **little endian** */ crc = (crc >> 1); } } // Prin conventie, o sa facem flip la toti bitii crc = ~crc; return crc; }

Note

Pentru o înțelegere mai bună a matematicii din spatele CRC, vă recomandăm următorul video (40 min).


Exerciții

Pentru laboratorul acesta vom folosi scheletul oferit la adresa: lab3. Tot acolo se gaseste si un README.md cu functionalitatea disponibila. Pentru a face update la ultima varianta a sheletului, folositi git pull.

Vom construi un protocol DataLink peste cel de data trecuta, astfel, avem deja implementata functionalitatea de transmisie de cadre. Avem la dispozitie doua servicii: link_recv si link_send ce se ocupa de framing si transmisia cadrelor. Procolul nostru este encapsulat in payload-ul acelui protocol. Vom considera un Maximum Transmission Unit (MTU) de 1500 bytes.

|--------------------| |LEN|CHECKSUM|PAYLOAD| |--------------------| \ / DELIM|PAYLOAD|DELIM -> Laboratorul trecut

1. In funcția simple_csum din schelet, implementați algoritmul de checksum asa cum a fost descris in laborator. Vom folosi structura protocolului definita in common.h. Receptorul va afisa Frame corrupted atunci cand detecteaza un cadru corupt. Vom folosi functia htonl pentru a trece valoarea pe care vrem o sa o scriem in campul sum in Network Order. Invers, la receiver, vom folosi ntohl pentru a trece in Host Order


Note

In scriptul run_experiment.sh avem campul CORRUPTION care manipuleaza rata de corupere a pachetelor.


2. Vom modifica implementarea precedenta pentru a folosi CRC 32 in loc de checksum.

3. Vom modifica protocolul pentru a realiza o retransmisie simpla. Transmitatorul va trimite un cadru si va astepta un raspuns de la receptor. Receptorul daca detecteaza un cadru corupt, va trimite un cadru prin care il va informa pe transmitator de acest lucru (NACK), altfel, va trimite o confirmare (ACK). Este la latitudinea voastra de cum veti codifica cele doua tipuri de raspunsuri. Vom testa implementarea prin trimiterea unui fisier in chunk-uri ce nu depasesc dimensiunea maxima impusa de MTU.

Pe link-ul nostru se strica doar 1 bit / cadru, putem astfel face codificaerea ACK/ NACK in asa fel incat daca este modificata, sa putem diferentia intre cele 2 tipuri de mesaje.

Lectură laborator


De parcurs înainte de laborator:


Routing

O să considerăm acum următorul scenariu. În marile capitale ale Europei avem mai multe dispozitive. De exemplu, în Londra, avem 4 dispozitive conectate prin Ethernet (3 calculatoare și un dispozitiv pe care îl vom numi router). La fel și în București. Dispozitivele numite "router" sunt conectate printr-un protocol de level 2 de tip Point-to-Point.

Vrem să trimitem un cadru de la Host A, în Londra, la Host B, în București. Dacă Host A, trimite un cadru de nivel 2, în acest caz Ethernet cu adresa destinație MAC B, acesta nu ar fi considerat de niciunul dintre dispozitive pentru că nimeni din Londra nu are aceastra adresa MAC. Dacă în schimb, am modifică aceste dispozitive numite routere să știe unde se află fiecare adresa MAC din toată europa, cel din Londra ar primi un cadru Ethernet de la Host A cu destinația MAC B și ar trimite conținutul acestuia către Paris folosind protocolul de tip PPP dintre acestea. Totuși, între Londra și Paris este o conexiune de tip PPP, destinația se pierde între aceste conexiuni deoarce protocoalele de tip PPP nu folosesc o destinație.

Avem nevoie de un protocol peste nivelul DataLink care să se ocupe cu identificarea și transmisia între ceea ce vom numi de acum rețele (e.g. rețeaua din București). În acest scop, a fost dezvoltat protocolul IP Protocol (IP) de nivel network. Astfel, o datagramă IP va fi encapsulata atât în protocolul Ethernet cât și în PPP, și routerele se vor ocupa de transmisie.

Protocoale utilizate

În cadrul laboratorului vom lucra cu pachete reale din internet. Astfel, vor fi folosite următoarele protocoale discutate la curs: Ethernet și IP.

Ethernet

Ethernet este echivalentul protocolului de DataLink pe care l-am implementat în primele laboratoare. Noi vom lucra doar cu cadre Ethernet ce sunt transmise ca payload peste implementarea protocolului de nivel fizic Ethernet. Cum CRC-ul este calculat în hardware, nu o să îl regăsim în header. În acest caz, header-ul pe care îl vom folosi este următorul:

Ethernet Frame +-----------------+------------+-------------+ | Bytes 0-5 | Bytes 6-11 | Bytes 12-13 | +------------------------------+-------------+ | Destination MAC | Source MAC | EtherType | +-----------------+------------+-------------+

Adresa MAC Destinație reprezintă identificatorul dispozitivului de nivel 2 către care a fost trimis acest cadru.

În cadrul laboratorului puteți folosi următoarea structura pentru un cadru Ethernet. Pentru câmpul EtherType ne interesează doar valoarea ETHERTYPE_IP (0x0800).

struct ether_header { uint8_t ether_dhost[6]; uint8_t ether_shost[6]; uint16_t ether_type; // ETHERTYPE_IP };

IPv4

Protocolul IP este utilizat pentru a permite dispozitivelor conectate în rețele diferite să schimbe informații prin intermediul unui dispozitiv intermediar numit router. Header-ul unui pachet (packet) IP este următorul:

+----+---------------+---------------+---------------+---------------+ |Word| 1 | 2 | 3 | 4 | +----+---------------+---------------+---------------+---------------+ |Byte|0|1|2|3|4|5|6|7|8|9|0|1|2|3|4|5|6|7|8|9|0|1|2|3|4|5|6|7|8|9|0|1| +----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 0|Version| IHL |Type of Service| Total Length | +----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 4| Identification |Flags| Fragment Offset | +----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 8| Time to Live | Protocol | Header Checksum | +----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 12| Source Address | +----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 16| Destination Address | +----+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

De notat faptul că astăzi nu vom atinge decât câmpurile Time to Live, checksum și adresa IP destinație. Câmpul Time to Live este un număr decrementat de fiecare router pentru a evita buclele. checksum este câmpul folosit pentru a verifică integritatea header-ului IP. Destionation Address este adresa IP a destinației.

Următoarea strucura poate fi folosită pentru a reprezenta un pachet IPV4.

struct iphdr { // The following syntax means that version has 4 bits and ihl 4 bits. uint8_t ihl:4, version:4; // don't care uint8_t tos; // don't care uint16_t tot_len; // don't care uint16_t id; // don't care uint16_t frag_off; // don't care uint8_t ttl; // Time to Live -> to avoid loops, we will decrement uint8_t protocol; // don't care uint16_t check; // checksum -> Since we modify TTL, // we need to recompute the checksum uint32_t saddr; // don't care uint32_t daddr; // the destination of the packet };

Observăm că o adresa IP precum 10.30.4.2 poate fi reprezentată în memorie ca un integer pe 32 de biți, uint32_t.

Câmpul checksum este complementul față de 1 al sumei tuturor cuvintelor de 16 biți din header. Totuși, cum noi modificăm doar câmpul TTL și pentru că checksum este o sumă, există o metodă mai rapidă de a calcula noul checksum folosind formulă:

Fie: HC - vechiul checksum din header C - complementul față de 1 al sumei campurilor din header HC' - noul checksum m - vechea valoare a câmpului de 16 biți (TTL în cazul nostru) m' - moua valoare a câmpului de 16 biți (TTl --) HC' = ~(C + (-m) + m') = HC + (m - m') = HC + m + ~m' complement față de 1 al noului mesaj

Cum header-ul IP o sa aiba mereu un camp diferit de zero, iar checksumul este complementul sumei, valoarea checksumului nu o sa ajunga niciodata -0 (0xFFFF). Astfel, formula precedenta poate ajunge sa returneze -0 cand ar trebui sa fie +0 (0x0000). Pentru a rezolva aceasta problema vom face urmatoarea modificare:

HC' = ~(C + (-m) + m') = ~(~HC + ~m + m')

Avem astfel formula finala:

new_checksum = ~(~old_check + ~((uint16_t)old_ttl) + (uint16_t)ip_hdr->ttl) - 1;

Acel -1 de la final apare pentru a evita translatia din network order in host order pentru valorile de ttl pe 16 biti.

Adrese IP

În general, o adresă IP este de forma 10.20.30.40 și este reprezentată pe 32 de biți.

Cum avem foarte multe adrese IP, în general o să le structurăm în blocuri (rețele). O rețea este identificată printr-un prefix și o mască. De exemplu, rețeaua din București în exemplul nostru este 10.20.30.0/24.

Network în București Network: 10.20.30.0/24 Prefix: 10.20.30.0 Mask: 255.255.255.0 (24 = nr de biți de 1 de la stânga la dreapta)

Câte adrese IP sunt în rețeaua din București? Avem 255 de adrese IP disponibile, 10.20.30.0 - 10.20.30.255. Adresele din acest bloc pot fi asignate dispozitivelor din București.

Procesul de forward (dirijiare)

Un router, pentru a trimite un pachet către următorul dispozitiv (hop) va trebui să realizeze mai multe acțiuni (proces de forward). Procesul complet de forwarding este următorul:

  1. Pe una dintre interfețe este recepționat un pachet IP.
  2. Verifică checksumul. Dacă acesta este greșit, aruncă pachetul
  3. Rulează algoritmul de Longest Prefix Match (LPM) în tabela de rutare pentru a găsi următorul hop.
  4. În cazul în care nicio intrare din tabela nu face match, routerul aruncă pachetul.
  5. Routerul decrementează câmpul TTL din header-ul IP. În cazul în care TTL este 0, pachetul este aruncat.
  6. Recalcuelaza checksum-ul folosind formula incrementală descrisă anterior.
  7. Routerul face update la adresa MAC sursă a pachetului în adresa proprie înainte de a îl trimite la următorul HOP și adresa MAC destinație a următorului HOP.
  8. Pachetul este trimis către următorul hop identificat prin LPM

Următorul slideshow prezintă acest proces:

Tabela de rutare este populată de algoritmii de routare (nu o să lucrăm cu ei la PCom) și este structurată astfel:

Prefix Next hop Mask Interface 192.168.0.0 192.168.0.2 255.255.255.0 0 192.168.1.0 192.168.1.2 255.255.255.0 1 192.168.2.0 192.168.2.2 255.255.255.0 2 192.168.3.0 192.168.3.2 255.255.255.0 3

Un dispozitiv are mai multe interfețe pe care poate trimite pachete (e.g. din Londra, are una pentru Paris și una pentru Berlin).

Longest Prefix Match

Pentru a determina prefixul dintr-o adresă IP și o mască, putem folosi următoarea operație pe biți: ip & mask. De exemplu:

Adresa IP Mask Prefix 10.20.30.4 & 255.255.255.0 = 10.20.30.0

Algoritmul are o specificație relativ simplă:

  • Routerul caută în tabela de rutare intrările care fac match pe adresa IP destinație din pachetul IPv4. (ip & mask) == prefix
  • Dintre toate rutele pe care s-a făcut match în etapă anterioară, este aleasă ruta cea mai specifică (adică ruta cu prefixul cel mai mare). Dacă două rute au aceeași specificitate, se va folosi ruta cu cel mai mic metric.

Un exemplu este cel din următoarea imagine, în care ruta a doua este cea mai specifică și următorul hop este conectat pe interfața S1.

Ruta 1 nu face match deoarece în acest caz (destIp & mask) != prefix. În schimb ruta 2 și 3 fac match. Totuși, după cum vedem și în imagine, ruta 2 este mai specifică.

O posibilă implementare în O(n) a algoritmului este următoarea:

// Avem o tabela de rutare table {prefix, next_hop, mask, interface} // tabela trebuie sortata descrescator prefix si masca // qsort((void *)table, table_len, sizeof(struct route_table_entry), comparator); for (int i = 0; i < table_len; i++) { /* Cum tabela este sortată, primul match este prefixul ce mai specific */ if (table[i].prefix == (target_ip & mask)) { return &table[i]; } }

Setup

Pentru a simula o rețea virtuală vom folosi Mininet. Mininet este un simulator de rețele ce folosește în simulare implementări reale de kernel, switch și cod de aplicații.

sudo apt update sudo apt install mininet openvswitch-testcontroller tshark python3-click python3-scapy xterm python3-pip sudo pip3 install mininet

După ce am instalat Mininet, vom folosi următoarea comandă pentru a crește dimensiunea fontului în terminalele pe care le vom deschide.

echo "xterm*font: *-fixed-*-*-*-18-*" >> ~/.Xresources xrdb -merge ~/.Xresources

Când o să rulăm simularea, e posibil să întâlniți următoarea eroare: Exception: Please shut down the controller which is running on port 6653:. Pentru a rezolva problema, va trebui să rulați pkill ovs-test.


NOTE

  • Pe unele versiuni mai vechi de Ubuntu este posibil să fie nevoie să instalați python-click și python-scapy fiind folosit Python 2.
  • Ar trebui sa mearga ok si pe WSL 2

Mininet

Mininet folosește python pentru a specifica o topologie. În următorul exemplu prezentăm o topologie formată din 3 dispozitive, dintre care unul acționează ca un router.

from mininet.topo import Topo from mininet.cli import CLI from mininet.net import Mininet class MyTopo(Topo): "O topologie este definită ca o clasa ce moștenește Topo" def build(self): # Adăugăm dispozitivele din topologie: hosts și un switch de L2 leftHost = self.addHost('host2') rightHost = self.addHost('host1') # Pe acest dispozitiv, teoretic ar trebui pornit un program ce # implementează protocolul de rutare router = self.addHost('router') # Adăugăm link-urile self.addLink(leftHost, router) self.addLink(rightHost, router) # Instantiem topologia topo = MyTopo() # Pornim simularea net = Mininet(topo) net.start() # Pornim terminalul de control al topologiei CLI(net) net.stop()

Vom folosi python pentru a rula topologia, presupunem că topologia se află în fișierul topo.py.

❯ sudo python3 topo.py mininet>

Din terminalul principal vom putea deschide terminale pe oricare dintre dispozitive:

# pornește un terminal pe host1 mininet> host1 xterm&

Puteti folosi orice terminal, nu doar xterm (E.g. gnome-terminal).

Comanda precedentă va deschide un terminal, din acel terminal vom putea executa orice binar de linux care se află și pe mașină noastră, precum Wireshark.

sudo wireshark&

Sau pentru depanare ping:

❯ ping router_ip PING router_ip (router_ip) 56(84) bytes of dată. 64 bytes from host_ip: icmp_seq=1 ttl=58 time=21.2 ms 64 bytes from host_ip: icmp_seq=2 ttl=58 time=21.5 ms 64 bytes from host_ip: icmp_seq=3 ttl=58 time=20.6 ms

Din terminalul unui dispozitiv putem rula ip address show pentru a vedea toate interfețele disponibile și adresele IP de pe acestea.

Exerciții

În laboratorul de astăzi vom implementa procesul de forwarding al unui router. Topologia este una simplă, avem 4 dispozitive (hosts) conectate la un router

Scheletul laboratorului se regăsește la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md

Vom folosi comanda ping pentru a verifica conectivitatea între hosts. Pentru depanare vom folosi Wireshark.

Pe router va fi rulat manual programul rezultat în urma rulării comenzii make. În cadrul laboratorului vom lucra în fișierul router.c.

1. Vom deschide o instanță de Wireshark pe router. Din oricare dintre hoști, trimiteți un ping către un alt host. Din Wireshark studiați pachetul primit. Ce protocoale sunt utilizate și care este relația dintre ele? Este util de știut faptul că Wireshark poate ascultă în același timp pe mai multe interfețe (avem 4 pe router).

Hostii au următoarele adrese alocate:

Host IPv4 MAC host0 192.168.0.2 de:ad:be:ef:00:00 host1 192.168.1.2 de:ad:be:ef:00:01 host2 192.168.2.2 de:ad:be:ef:00:02 host3 192.168.3.2 de:ad:be:ef:00:03

2. Implementați procesul de forward pentru IPv4. Tabela de rutare este reprezentată printr-un array și poate fi accesată la un index prin variabila rtable[i]. rtable_size este dimensiunea tabelei. Nu trebuie implementată parsarea fișierului rtable.txt. Structura de date utilizate pentru o intrare din tabelă este route_table_entry din lib.h.

2.1. Verificați integritatea header-ului IP folosind câmpul de checksum din header. Dacă acesta nu este bun, vom da drop la pachet.

2.2. Folosind algoritmul de LPM, determinați următorul hop și interfața pe care va trebui să trimitem pachetul.

Continutul tabelei de rutare este in network order. In cazul unei comparatii, vom trece valorile in host order (e.g. ntohl(mas1) > ntohl(mask2)).

2.3. Pentru fiecare pachet scădeți TTL-ul sau Hop Limit-ul; dacă TTL-ul este pozitiv, recalculați checksum-ul.

2.4. Actualizați header-ul Ethernet al pachetului. În funcție de adresa IP a destinației, vom căuta în tabela MAC adresa MAC a următorului hop. Tabela este statică și putem interacționa cu ea folosind funcțiile din lib.h.

Pentru a putea trimite pachetul IP mai departe, trebuie să completăm adresa MAC a hostului următor. În mode normal, tabela MAC este populată folosind protocolul ARP, în cadrul acestui laborator vom considera o tabelă statică deja populată folosind ARP


Note

Puteți folosi inet_pton pentru a parsa adresele IPv4. Pentru a parsa MAC-ul în binar puteți folosi funcția hwaddr_aton pusă la dispoziție în lib.h.

Funcția int get_interface_mac(int interface, uint8_t *mac) întoarce adresa MAC a interfeței router-ului.


Lectură laborator


De parcurs înainte de laborator:


Nivelul Transport

Protocoalele de nivel transport folosesc servicile oferite de către nivelul rețea. În internet, nivelul rețea ofera un serviciu fără conexiune. Nivelul rețea identifica fiecare host folosind o adresa IP. Nivelul retea poate transmite pachete ce au până la 65KBytes de date către orice desținație cunoscută din rețeaua locală sau din Internet.

Nivelul rețea nu garantează transmiterea datelor, nu poate detecta erori de transmisie a datelor și nu păstrează ordinea de transmisie. Toate aceste lipsuri sunt rezolvate de către protocoalele de nivel transport.

În general, implementarea protocoalelor de nivel transport se face în sistemul de operare.

Porturi

Porturile sunt conceptul ce ne ajută să facem multiplexare între aplicații.

În contextul rețelelor de comunicație, un port este un număr asociat unui socket dintr-un proces (nu unui host). Dacă un proces dorește să comunice cu alte procese, aceasta expune un port, o locație logică prin care accepta conexiuni sau prin care se realizează schimbul de date.

Aceste numere permit aplicațiilor să partajeze concurent resursele de rețea. Serverul de mail, de exemplu, nu așteapta terminarea altor procese ce implica reteaua (ex. web surfing) pentru a putea trimite un mail la destinație.

În antetul protocoalelor de nivel transport, portul este reprezentat pe 2 bytes: uint16_t port;.

Mai multe porturi au fost rezervate în procesul de standardizare. Astfel, în RFC 1340 gasiți o listă de porturi care sunt considerate ca fiind rezervate (sau well-known) pentru anumite protocoale. De exemplu, portul 21 este rezervat pentru File Transfer Protocol (FTP).

UDP

Serviciu neorientat conexiune: nu se stabilește o conexiune între client și server. Așadar, serverul nu va așteapta apeluri de conexiune, ci așteaptă direct datagrame de la clienti. Acest tip de comunicare este întâlnit în sistemele client-server în care se transmit puține mesaje și în general prea rar pentru a menține o conexiune activă între cele două entități.

Nu se garantează ordinea primirii mesajelor și nici corectarea pierderilor pachetelor. UDP-ul se utilizeaza mai ales în rețelele în care există o pierdere foarte mică de pachete și în cadrul aplicatiilor pentru care pierderea unui pachet nu este foarte importantă (Un exemplu: aplicațiile streaming video).

Are un overhead foarte mic, în comparație cu celelalte protocoale de transport (Are un header de 8 bytes, în comparație cu TCP-ul care are minim 20 bytes)

Header UDP

Header-ul UDP are 8 bytes si are urmatoarea structura:

0 7 8 15 16 23 24 31 +--------+--------+--------+--------+ | Source | Destination | | Port | Port | +--------+--------+--------+--------+ | Length | Checksum | +--------+--------+--------+--------+

Portul sursa este ales random de către mașina sursa a pachetului dintre porturile libere existente pe acea mașina. Este un număr pe 16 biti, între 0 si 65535. Identifică procesul UDP care a transmis datagrama.

Portul destinatie este portul pe care mașina destinatie poate recepționa pachete. Identifică socket-ul UDP care va procesa datele primite.

Length este lungimea în octeti (bytes) a datagramei (header size + data size).

Checksum este valoarea sumei de verificare pentru datagrama.

Putem folosi următoarea structură pentru a reprezenta header-ul UDP:

struct udphdr { uint16_t sport; /* source port */ uint16_t dport; /* destination port */ uint16_t ulen; /* udp length */ uint16_t sum; /* udp checksum */ };

Sockets

În cadrul laboratorului nu vom implementa protocolul UDP, ci vom folosi implementarea existentă din Kernel-ul de Linux. Acest lucru se realizează prin intermediul API-ului de sockets. Network stack-ul din Linux se ocupă de parsarea și interactiunea cu datagramele UDP, nouă returnând-se doar conținutul datagramei.

Un socket este un canal generalizat de comunicare între procese, reprezentat în Linux/UNIX print-un descriptor de fișiere. El ofera posibilitatea de comunicare între procese aflate pe masini diferite într-o retea.

API-ul de sockets poate fi folosit și pentru IPC (Inter-Process Communication) între procese ce rulează pe aceeași mașină, prin specificarea adresei de loopback sau a unei interfețe existente pe mașină.

Comunicare client-server UNIX

Intr-o arhitectură client-server, clientul trimite request-uri (cere resurse) către server, iar acesta din urmă trimite înapoi un raspuns (cu resursa).

Pasi urmați pentru a schimba mesaje folosind UDP la nivelul Transport folosind API-ul de sockets sunt următorii:

  1. Deschide un socket unix în scopul de a permite comunicarea între procese/statii diferite folosind descriptori de fisiere (file descriptors) cu apelul socket().
  2. Asociaza o adresa pentru socketul deschis cu apelul bind(). În general, folosim bind() atunci când dorim să așteptam datagrame pe un anumit port. Bind este chemat pe server pentru a specifica la ce port să lege socket-ul.
  3. Trimite/Recepționeaza date cu apelul recvfrom()/sendto().
  4. Închide socket prin close().
+--------+ +--------+ | Server | | Client | +--------+ +--------+ | | | | Descriere: socket() socket() socket() - creează un endpoint de comunicare | | bind() - atașează o adresa unui socket bind() | sendto() - trimite o datagrama | | receive() - primește o datagramă recv_from() <-------- sendto() close() - eliberează file descriptorul | | send_to() --------> recvfrom() | | close() close()

O implementare simplă de client care trimite o datagramă:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> #define PORT 50000 #define MAXLINE 1024 int main() { int sockfd; char buffer[MAXLINE]; char *hello = "Hello from client"; struct sockaddr_in servaddr; // Creating socket file descriptor if ( (sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0 ) { perror("socket creation failed"); exit(EXIT_FAILURE); } memset(&servaddr, 0, sizeof(servaddr)); // Filling server information servaddr.sin_family = AF_INET; servaddr.sin_port = htons(PORT); servaddr.sin_addr.s_addr = INADDR_ANY; int n, len; sendto(sockfd, (const char *)hello, strlen(hello), 0, (const struct sockaddr *) &servaddr, sizeof(servaddr)); printf("Hello message sent.\n"); n = recvfrom(sockfd, (char *)buffer, MAXLINE, MSG_WAITALL, (struct sockaddr *) &servaddr, (socklen_t *)&len); buffer[n] = '\0'; printf("Server : %s\n", buffer); close(sockfd); return 0; }

Implementarea server-ului care primește această datagramă este următoarea:

#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/uio.h> #include <unistd.h> #define PORT 50000 #define MAXLINE 1024 int main() { int sockfd; char buffer[MAXLINE]; char *hello = "Hello from server"; struct sockaddr_in servaddr, cliaddr; // Creating socket file descriptor if ( (sockfd = socket(AF_INET, SOCK_DGRAM, 0)) < 0 ) { perror("socket creation failed"); exit(EXIT_FAILURE); } memset(&servaddr, 0, sizeof(servaddr)); memset(&cliaddr, 0, sizeof(cliaddr)); // Filling server information servaddr.sin_family = AF_INET; // IPv4 servaddr.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY = 0.0.0.0 servaddr.sin_port = htons(PORT); // Bind the socket with the server address if ( bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0 ) { perror("bind failed"); exit(EXIT_FAILURE); } int n; // len is an unsinged int value/result, but it is recomanded // to use the socklen_t type socklen_t len = sizeof(cliaddr); n = recvfrom(sockfd, (char *)buffer, MAXLINE, MSG_WAITALL, (struct sockaddr *) &cliaddr, &len); buffer[n] = '\0'; printf("Client : %s\n", buffer); sendto(sockfd, (const char *)hello, strlen(hello), 0, (const struct sockaddr *) &cliaddr, len); printf("Hello message sent.\n"); return 0; }

socket()

#include <sys/types.h> #include <sys/socket.h> /* creare socket in C */ /* int socket(int domain, int type, int protocol); */ /* pentru UDP, folosim un socket de tip SOCK_DGRAM */ int sockid = socket(PF_INET, SOCK_DGRAM, 0); if (sockid == -1) { /* trateaza eroare */ }

Explicatii:

  • sockid - file descriptor pentru socket. În caz de eroare se întoarce -1 si se seteaza variabila errno.
  • domain - reprezintă familia de protocoale pe care urmează să le utilizam în transferul informației. Vom folosi valorile PF_INET pentru IPv4 sau PF_INET6 pentru IPv6.
  • type - reprezinta tipul socketului. Valori uzuale:
    • SOCK_STREAM - Indicata stabilirea unei comunicatii bazata pe construirea unei conexiuni intre sursa si destinatie. Comunicatia este FIFO, fiabila si sigura, o vom folosi la laboratorul urmator cu TCP.
    • SOCK_DGRAM - Ofera un flux de date bidirectional, care nu promite sa fie sigur, in secventa sau neduplicat. Un proces care receptioneaza mesaje pe un socket datagrama, poate gasi mesaje duplicate si posibil intr-o ordine diferita fata de cea in care au fost trimise.
  • protocol - specifica protocolul de transport utilizat. Vom seta pe valoarea 0, pentru a se alege protocolul corect in functie de type.

Pentru a afla mai multe informatii, putem accesa urmatorul capitol5.2 socket()—Get the File Descriptor!

bind()

Utilizată în server pentru a lega un socket de un port și eventual o anumită adresă. Practic, bind este folosit pentru a indica implementării de networking din Kernel să lege acel socket la un anumit port și (opțional) la o anumită adresă IP. Astfel, stiva va trimite către acel socket doar datagramele ce au ca port destinație portul ales.

#include <sys/types.h> #include <sys/socket.h> /*int bind(int sockfd, struct sockaddr *my_addr, int addrlen)*/ struct sockaddr myaddr; memset(&myaddr, 0, sizeof(servaddr)); myaddr.sin_family = AF_INET; // IPv4 /* INADDR_ANY = 0.0.0.0 as uint32 */ myaddr.sin_addr.s_addr = INADDR_ANY; myaddr.sin_port = htons(atoi(8888)); int rs = bind(sockfd, myaddr, sizeof(servaddr); /* in urma apelului, sockfd va avea adresa my_addr */ if (rs == -1) { /* trateaza eroare* / }

Explicații:

  • sockfd - Descriptorul de fișier returnat de socket();
  • my_addr - Structura sockaddr ce conține informații despre adresa IP și port;
  • addrlen - lungimea structurii ce stochează adresa. (a lui my_addr).

Pentru a afla mai multe informatii, putem accesa 5.3 bind()—What port am I on?.

recvfrom()/ sendto()

Funcțile sunt folosite pentru a primi/trimite o datagrama peste un socket. Mai multe detalii găsiți aici.

#include <sys/types.h> #include <sys/socket.h> struct sockaddr to; // Filling server information memset(&to, 0, sizeof(servaddr)); to.sin_family = AF_INET; to.sin_port = htons(8888); int rc = inet_aton("127.0.0.1", &to.sin_addr); int byteswrite = sendto(int sockfd, char *buff, int nbytes, int flags, struct sockaddr *to, int addrlen); if (byteswrite == -1) { /* trateaza eroare */ } /* from va fi populata de apelul recvfrom si va contine informatii despre cine a trimis datagrama catre noi */ struct sockaddr from; int bytesread = recvfrom(int sockfd, char *buff, int nbytes, int flags, struct sockaddr *from, int *addrlen); if (bytesread == -1) { /* trateaza eroare */ }

Explicatii:

  • sockfd - Descriptorul de fisier returnat de socket()
  • buff - Bufferul unde se găsesc datele ce urmează a fi trimise/bufferul unde se vor recepționa datele;
  • flags - Precizează condiții de efectuare a transmisiei;
  • to/from - Structura ce indica adresa unde se trimite/de unde se primesc date. In cazul lui recvfrom() se populeaza de către funcție.
  • addrlen - Lungimea structurii to/from în octeți. În cazul recvfrom(), este un pointer care trebuie să indice, la început, către o valoare egală cu dimensiunea zonei de memorie alocate pentru structura de tip adresă și este completă (restrânsă) - dacă este necesar - de către funcție.

close()/ shutdown()

Pentru a închide un socket se foloseste funcția de închidere a unui descriptor de fisier din Unix:

#include <unistd.h> int close(ind fd);

Acest lucru va împiedica atât realizarea de alte citiri, cât și de scrieri din socket. Pentru mai mult control asupra socketului, se folosește funcția shutdown(), care permite întreruperea comunicatiei selectiv, schimband modul de utilizare a legaturii full-duplex.

Pentru a afla mai multe informatii, putem accesa 5.9 close() and shutdown()—Get outta my face!.

#include <sys/socket.h> int shutdown(int sockfd, int how);

Explicatii:

  • sockfd - Descriptorul de fișier returnat de socket()
  • how - Specifica modul de inchidere: SHUT_RD - Nu se mai citesc date. SHUT_WD - Nu se mai pot face transmiteri de date. SHUT_RDWR - Se intrerupe comunicația în ambele direcții.

Note

Shutdown() nu închide un descriptor de fisier, ci doar îi schimbă modul de utilizare. Resursele trebuie eliberate folosind close().


Beyond C/C++

API-ul de sockets este o o abstractizare foarte răspândită. Practic majoritatea interacțiunilor cu Internetul o să fie prin intermediul API-ul de sockets din sistemul de operare. Spre exemplu, orice implică networking în Python funcționează peste o abstractizare de sockets din limbaj care cheamă apelurile de sistem de sockets. În codul de mai jos putem vedea un astfel de exemplu.

  • Python
  • Java
  • Go
import socket localIP = "127.0.0.1" localPort = 20001 bufferSize = 1024 msgFromServer = "Hello UDP Client" bytesToSend = str.encode(msgFromServer) # Create a datagram socket UDPServerSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM) # Bind to address and ip UDPServerSocket.bind((localIP, localPort)) print("UDP server up and listening") # Listen for incoming datagrams while(True): bytesAddressPair = UDPServerSocket.recvfrom(bufferSize) message = bytesAddressPair[0] address = bytesAddressPair[1] clientMsg = "Message from Client:{}".format(message) clientIP = "Client IP Address:{}".format(address) print(clientMsg) print(clientIP) # Sending a reply to client UDPServerSocket.sendto(bytesToSend, address)


Stop-And-Wait

Un protocol foarte simplu pe care îl putem dezvolta peste protocolul UDP se numește Stop-and-Wait. În imaginea de mai jos avem o reprezentare grafică a acestui protocol. Presupunem că nu exista pierderi pe link-urile dintre host și receiver.

Transmitatorul, trimite o datagrama UDP, așteaptă confirmarea de la receptor, iar apoi trimite următoarea datagrama UDP. ACK-ul este tot o datagrama, doar ca aceasta nu cară date, ci doar confirmă primirea datagramei anterioare.

Protocolul nostru simplu, are totuși o problemă: nu foloseste link-urile optim. Dacă noi am avea un link de 100Mbps cu un delay de 100ms între Sender și Receiver, atunci protocolul în forma actuală ar avea un throughput de sub 3% din bandă deoarece o datagrama UDP poate avea cel mult 65507 bytes (atunci când folosim IPv4). Pentru a rezolva aceasta problemă, a fost dezvoltată tehnica de fereastră glisantă.

Fereastra Glisanta (Sliding Window)

Pentru a folosi un link într-un mod optim, vom folosi tehnica de fereastră glisantă (sliding window). Vom trimite window_size datagrame fără să așteptăm după un ACK, apoi pentru fiecare ACK primit, vom face slide fereastrei la dreapta.

Următoarea simulare prezintă un schimb de datagrame pentru o fereastra de 5. Pentru a porni simularea apăsați Start Simulation.

Dimensiunea ferestrei

Vom presupune un caz simplu în care 2 gazde pot comunica datagrame UDP peste mai multe link-uri:

L1 L2 L3 Host A <------> Router <--------> Switch <-------> Host B L1, L2, L3 - 10 MBps, 5ms latenta, 0% pierderi de pachete

Cum calculam dimensiunea ferestrei? Cum toate link-urile au aceiasi parametrii, vom face calculul o singură dată. Primul pas este determinarea valorii BDP-ului (Bandwidth Delay Product):

BDP=10MB/s5ms=10106B/s5103s=50000bytes=50KB

În cazul în care datagramele pe care le trimitem au cel mult 1500 bytes, atunci pentru a folosi link-ul într-un mod optim, dimensiunea ferestrei este următoarea:

windowssize=[BDP/DatagramSize]=[50000/1500]=30

Am presupus ca dimensiunea maxima de 1500 bytes include și antelele protocoalelor de nivel inferior precum IP și Ethernet.

Exercitii

Scheletul laboratorului se regăsește la adresa de aici. Verificati sa aveti ultima versiune de schelet. Funcționalitatea disponibilă este descrisă în README.md. În continuare vom folosi Mininet pentru a simula topologia simpla: sudo python3 topo.py.

1. Scheletul de cod are o implementare simplă de client-server peste UDP folosind API-ul de sockets. Vom deschide pe router o instanță de Wireshark pentru a studia datagrama trimisa de sender. Cum arată datagramele trimise de către sender? Care este portul sursa?

Pe h1 vom rula clientul, iar pe h2 serverul. Routerul rulează pe r0.

2 Plecând de la scheletul de cod implementați protocolul stop-and-wait pentru a trimite conținutul unui fisier de la sender la receiver. Fisierul va fi trimis în bucati de maxim 1024 bytes. La final, vom măsura timpul de rulare al serverului. Topologia din Mininet pe care o vom folosi este următoarea:

L1 L2 client (h1) <--> router (r1) <--> server (h2) Link 1 - 10 Mbps, 5ms delay, 0% packet loss (linia 57 din topo.py) Link 2 - 10 Mbps, 5ms delay, 0% packet loss (linia 60 din topo.py) h1 IP: 192.168.1.100 h2 IP: 172.16.0.100

Note

După ce am testat manual ca implementarea este corectă, vom folosi sudo python3 topo.py benchmark pentru a măsura performanța implementării noastre. De exemplu, dat fiind că avem un delay de 5 ms, observam un runtime total al serverului de 30 ms, din care 10 ms cât a stat blocat în recv() ca să ajungă datagramele la el și alte 20 ms din alte surse (e.g. overhead de la OS). Exemplu de output:

##### Benchmark results ##### [Server] Received: Hello world! Total time = 0.030401 seconds #############################

3 Pentru a folosi link-ul intr-un mod optim, vom modifica implementarea de stop-and-wait sa folosească o fereastră glisantă de dimensiune optimă. Astfel, vom calcula BDP-ul pentru fiecare link si vom alege minimul dintre ele, pentru a evita congestionarea ambelor segmente. Dimensiunea (în datagrame a) ferestrei optime este [BDP / datagram_size]. Cu ce procent este mai rapidă aceasta implementare?

4 Ne vom conecta toți în aceași rețea (e.g. pe acelasi WiFi) pentru a trimite fișiere către alți colegi din sala. Nu vom mai rula peste Mininet. Ne vom grupa câte doi pentru a trimite un fișier de la unul la celalalt. Mai exact, unul dintre voi va porni un server cu bind pe INADDR_ANY (0.0.0.0), iar clientul va putea trimite catre ip-ul celuilalt fisierul.


Note

Dacă folosiți mașini virtuale și nu aveți rețeaua pe modul Bridge, va trebui să faceți port forwarding din setarile hypervisorului. Pentru a verifica dacă sunteți în aceeasi retea, folositi ip address show și confirmați că adresa este din același subnet cu cel al gazdei.


Lectură laborator


De parcurs înainte de laborator:


Retransmisie peste UDP

In laboratorul precedent am dezvoltat un protocol simplu cu fereastra glisanta peste un link ideal. Totusi, in realitate, link-urile au pierderi. Astazi, vom dezvolta un alt protocol peste UDP cu retransmisie. Acesta va asigura transferul corect de date intre un server si un client peste un link care pierde date.

In acesta laborator, unitatea de transmisie pe care o vom folosim este segmentul.

Go Back N ARQ

O tehnică dezvoltată pentru a realiza retransmisia este Go-Back-N ARQ. Este un caz special de fereastră glisantă, în care transmitătorul are o fereastră N și receptorul 1. La receptor, orice segment care nu este așteptat este aruncat. Transmitătorul retransmite toate segmentele din fereastră la declanșarea unui timer.

Simularea urmatoare prezinta surprinde acest comportament. Pentru a pierde click pe un segment, apasati click pe acesta.

Pseudocodul aferent implementarii este urmatorul. Consideram ca segmentele se pierd, coruperea fiind detectata de nivelele inferioare si pachete fiind aruncate.

N = window size Rn = request number Sn = sequence number Sb = sequence base Sm = sequence max void receiver() { Rn = 0; while (1) { if the segment received = Rn then Accept the segment and send the payload to upper lever Rn := Rn + 1 else Drop packet Send ACK with Sequence Number Rn } } void sender() { Sb := 0 Sm := N + 1 Transmit Sm segments while (1) { if you receive an ACK with Sequence number Rn > Sb then Transmit segments from Sm to Sm + Rn - Sb Sm := (Sm − Sb) + Rn Sb := Rn if Timeout Expired Transmis Sm backets from Sb } }

Implementarea ferestrei glisante

Fereastra glisantă poate fi implementată sub forma unei liste. Lista va conține toate segmentele. Vom folosi min_seq si max_seq pentru a determina pozitia ferestrei. Ne vom baza pe o implementare de listă simplu inlanțuită.

/* List entry */ struct cel{ /* Pointer catre segment */ void* info; /* Dimensiunea segmentului header + date */ int info_len; /* Numar de secventa segment */ int seq; struct cel* next; }; typedef struct cel list_entry; /* Window as a list */ typedef struct { int size; int max_seq; list_entry* head; }list; /* Adauga in lista un segment. In info este stocat segmentul, len este dimensiunea segmentului si seq numarul de secventa */ void add_list_elem(list* window, void* segment, int len, int seq); void display_list_seq(list* window); list* create_list(int max_size);

Vizual, implementarea arata astfel:

Slot-urile gri pot conține sau nu segmente.

Vom presupune ca am determinat window_size = max_seq - min_seq în funcție de BDP. Atât la început, cât și la retransmisie, în implementarea de Go-Back-N vom transmite toata fereastra.

/* Functia trimite toate pachetele din fereastra */ int send_window(list *window) { list_entry* e = window->head; while(e != NULL && e->seq < window->max_seq) sendto(sockfd, e->info, e->info_len, &serveraddr, sizeof(serveraddr)); }

Ce facem atunci când primim un ACK cu numărul de secvență seq? Etichetăm segmentul ca fiind primit prin faptul ca îl scoatem din listă.

/* Scoate din lista segmentele care au fost primite */ int update_left_window(list *window, int seq) { list_entry* e = window->head; list_entry *prev = NULL; int count = 0; while(e != NULL && e->seq <= seq) { prev = e; e = e->next; free_entry(e); count++; } l->head = e; return count; }

Acum ca fereastra s-a eliberat de count segmente. Noul max_seq = max_seq + count, așa că trebuie să mutăm fereastra la dreapta și să trimitem urmatoarele segmente.

int send_new_segments(list *window, int new_max_seq) { list_entry* e = window->head; /* Sarim peste segmentele deja trimise */ while(e != NULL && e->seq <= window->max_seq) e = e->next; if (e != NULL) { /* Trimitem urmatoarele segmente pana la new_max_seq */ while(e != NULL && e->seq <= new_max_seq) { e = e->next; sendto(sockfd, e->info, e->info_len, &serveraddr, sizeof(serveraddr)); } } window->max_seq = new_max_seq; }

Numarul de secventa

In general, vom implementa numarul de secventa incremental dupa numarul de segmente:

Sau incremental dupa numarul de bytes:

Exercitii

Scheletul laboratorului se regaseste la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md. În continuare, vom folosi Mininet pentru a simula o topologie simplă: sudo python3 topo.py.

Antetul protocolului pe care îl dezvoltăm astăzi este descris mai jos. Protocolul îl vom implementa peste UDP.

|Seq Number|Length|

1. Vom dezvolta un protocol simplu de retransmisie. Vom trimite un segment, dacă primim un ACK, trimitem segmentul cu secvența precedentă + 1. Vom folosi o opțiune specială pe socket pentru a seta un timer de o secundă pe el. Astfel, dacă recv nu primește date într-un anumit timp, o să expire timer-ul și o să retransmitem toate segmentele din fereastra.

struct timeval timeout; timeout.tv_sec = 1; timeout.tv_usec = 0; if (setsockopt (sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout) < 0 error("setsockopt failed\n");

Vom rula Wireshark pe router pentru a face depanarea.

2. Vom extinde implementarea precedentă de protocol pentru a folosi tehnica de fereastra glisantă. Vom implementa Go-Back-N. Vom calcula dimensiunea ferestrei de transmisie în funcție de lungimea de bandă și latență.

3 Ne vom conecta toți în aceași rețea (e.g. pe acelasi WiFi) pentru a trimite fișiere către alți colegi din sala. Nu vom mai rula peste Mininet. Ne vom grupa câte doi pentru a trimite un fișier de la unul la celalalt. Mai exact, unul dintre voi va porni un server cu bind pe INADDR_ANY (0.0.0.0), iar clientul va putea trimite catre ip-ul celuilalt fisierul.


Note

Dacă folosiți mașini virtuale și nu aveți rețeaua pe modul Bridge, va trebui să faceți port forwarding din setarile hypervisorului. Pentru a verifica dacă sunteți în aceeasi retea, folositi ip address show și confirmați că adresa este din același subnet cu cel al gazdei.


Lectură laborator


De parcurs înainte de laborator:


Protocolul TCP

TCP (Transport Control Protocol) este un protocol ce furnizează transmisie garantată (cât timp există conexiune), în ordine și o singură dată, a octeţilor de la transmiţător la receptor. Acest protocol asigură stabilirea unei conexiuni între cele două calculatoare pe parcursul comunicaţiei și este descris în RFC 793. Protocolul TCP are următoarele proprietăţi:

  • stabilirea unei conexiuni între client și server; serverul va aștepta apeluri de conexiune din partea clienților
  • garantarea ordinii primirii mesajelor şi prevenirea pierderii pachetelor
  • controlul congestiei (fereastră glisantă)
  • overhead mai mare în comparaţie cu UDP (are un header de 20 bytes, spre deosebire de UDP, care are doar 8 bytes).

Header TCP

Explicaţii header:

  • portul sursă este ales random de către maşina sursă a pachetului, dintre porturile libere existente pe acea maşină
  • portul destinaţie este portul pe care maşina destinaţie poate recepţiona pachete
  • checksum este valoarea sumei de control pentru un pachet TCP

Pentru a înțelege mai bine cum funcționează protocolul TCP, vom rula Wireshark pe pe interfata loopback.

Vom deschide un server TCP simplu folosind nc:

nc -l 8083

Din alt terminal ne vom conecta la acest server

telnet -4 localhost 8083

În primele 3 pachete TCP, putem observa operația de three-way handshake între client (browser) și server. În acest caz, observăm că numărul de secvență atât la server cât și la client pornește de la 0 (SEQ = 0, ACK = 0). Daca scriem in telnet text si apasam Enter o sa putem vedea in Wireshark segmentele trimise de TCP.

Sockets API for TCP

La laboratorul precedent am discutat funcțiile socket, bind, recvfrom și sendto pe care le puteam folosi pentru a trimite datagrame UDP. În acest laborator, vom folosi trei funcții noi: connect, listen si accept. Acestea sunt folosite pentru stabilirea unei conexiuni între sender și receiver.

În plus, în cadrul acestui laborator vom folosi funcțiile send și rev în locul funcțiilor recvfrom si sendto deoarece odată stabilită o conexiune, nu mai trebuie să specificăm destinația. Găsiți în imaginea de mai jos un overview a cum sunt realizate acestea.


Note

In cadrul functie socket vom folosi SOCK_STREAM ca argument in locul SOCK_DGRAM.


connect()

În client, după ce am creat socketul, acesta trebuie să se conecteze la server (e.g. sa inițieze și să stabilească un three-way handshake). Pentru asta vom folosi funcția connect():

#include <sys/types.h> #include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  1. sockfd este un descriptor de fişier obţinut în urma apelului socket(),
  2. addr este o structură ce conţine portul şi adresa IP ale serverului,
  3. addrlen reprezint[] dimensiunea celui de-al doilea parametru.

Rezultatul întors de connect este 0 în caz de success și -1 în caz de eroare.

listen()

Comunicaţia prin conexiune stabilă este asimetrică. Mai precis, unul din cele două procese implicate joacă rol de server, iar celălalt joacă rol de client. Cu alte cuvinte, serverul trebuie să îi asocieze socketului propriu o adresă pe care oricare client trebuie să o cunoască, şi apoi să "asculte" pe acel socket cererile ce provin de la clienţi. Mai mult decât atât, în timp ce serverul este ocupat cu tratarea unei cereri, există posibilitatea de a întârzia cererile ce provin de la alţi clienţi, prin plasarea lor într-o coadă de aşteptare. Setarea unui socket pentru a fi pasiv se face prin intermediul funcției neblocante slisten():

#include <sys/types.h> #include <sys/socket.h> int listen(int sockfd, int backlog); /* Usage example: After calling bind in the server, we listen at most 5 connections */ if ((listen(sockfd, 5)) != 0) { printf("Listen failed...\n"); exit(0); }
  1. sockfd reprezintă descriptorul de fişier obţinut în urma apelului socket(),
  2. backlog indică numărul de conexiuni acceptate în coada de aşteptare.

Conexiunile care se fac de către clienți vor aştepta în aceasta coadă până când se face accept(). Nu pot fi mai mult de backlog conexiuni în aşteptare.

Apelul listen() întoarce 0 în caz de success şi -1 în caz de eroare.

accept()

Ce se întâmplă în momentul în care un client încearcă să apeleze connect() către o maşină şi un port pe care s-a facut în prealabil listen()? Conexiunea va fi pusă în coada de aşteptare până în momentul în care se face un apel de accept() de către server. Acest apel întoarce un nou socket care va fi folosit pentru conexiune:

#include <sys/types.h> #include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); /* Usage example: after calling listen we can call accept to accept a connection from the queue */ int len; struct sockaddr_in cli; /* cli and len are written by the call with the info about the connected client (e.g. port, address) */ connfd = accept(sockfd, (struct sockaddr *)&cli, &len);
  1. sockfd reprezintă socketul pe care s-a făcut listen() (deci cel întors de apelul socket())
  2. addr reprezintă un pointer spre o structură de tip struct sockaddr în care se va afla informaţia despre conexiunea făcuta (ce maşină de pe ce port a iniţiat conexiunea). Noul socket obţinut prin apelul accept() va fi folosit în continuare pentru operaţiile de transmisie și recepție de date
  3. addrlen reprezintă dimensiunea (în bytes) a structuri addr

Funcția accept() întoarce un nou socket, care va fi folosit pentru operațiile de tipul send() / recv().

send/recv

Aceste două funcţii se folosesc pentru a transmite date prin sockeţi de tip stream sau sockeţi de tip datagramă conectaţi. Sintaxa pentru trimitere şi primire este asemănătoare. Pentru trimitere, se folosește funcția send():

#include <sys/types.h> #include <sys/socket.h> ssize_t send(int connfd, const void *buf, size_t len, int flags);

Note

Atenție la socket-ul pe care send îl primeste. Acesta este socket-ul returnat de accept de la un client. sockfd îl folosim doar pentru a primi conexiuni de la clienți.


  1. connfd este socketul căruia se dorește să se trimită date (fie este returnat de apelul socket(), fie de apelul accept()).
  2. buf este un pointer către adresa de memorie unde se găsesc datele ce se doresc a fi trimise,
  3. len reprezintă numărul de octeți din memorie începand de la adresa respectivă ce se vor trimite.
  4. flags reprezintă o combinație de flag-uri ce poate altera modul de transmitere al mesajului.

Functia întoarce numărul de octeți efectiv trimiși (acesta poate fi mai mic decât numărul care s-a precizat că se dorește a fi trimis, adică len). În caz de eroare, funcția returnează -1, setându-se corespunzător variabila globală errno.

Pentru recepție de date, se folosește funcția recv():

#include <sys/types.h> #include <sys/socket.h> ssize_t recv(int connfd, void *buf, size_t len, int flags);
  1. connfd reprezintă socketul de unde se citesc datele,
  2. buf reprezintă un pointer către o adresă din memorie unde se vor scrie octeții citiți,
  3. len reprezintă numărul maxim de octeți ce se vor citi.

Funcția recv() întoarce numărul de octeți efectiv citiți în buf sau -1 în caz de eroare.

Observații:

  1. recv() poate întoarce și 0, acest lucru însemnând că entitatea cu care se comunică a închis conexiunea
  2. pentru scrierea/citirea în/din sockeți TCP, se pot folosi cu succes și functiile write() și read (foarte asemănătoare cu send() și recv()), mai puțin câmpul flags, care va fi setat la valoarea 0

Exemplu client

Pentru a înțelege mai bine, vom studia următoarea implementare de server și client ce folosește API-ul de sockeți:

  • Client code, sends hello world message
#include <stdio.h> #include <string.h> #include <sys/socket.h> #include <arpa/inet.h> int main(void) { int socket_desc; struct sockaddr_in server_addr; char server_message[2000], client_message[2000]; /* Clean buffers and structures*/ memset(server_message,'\0',sizeof(server_message)); memset(client_message,'\0',sizeof(client_message)); memset(&server_addr, 0, sizeof(server_addr)); /* Create socket, we use SOCK_STREAM for TCP */ socket_desc = socket(AF_INET, SOCK_STREAM, 0); if(socket_desc < 0){ printf("[CLIENT] Unable to create socket\n"); return -1; } printf("[CLIENT] Socket created successfully\n"); /* Set port and IP the same as server-side */ server_addr.sin_family = AF_INET; server_addr.sin_port = htons(2000); server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); /* Send connection request to server */ if(connect(socket_desc, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0){ printf("[CLIENT] Unable to connect\n"); return -1; } printf("[CLIENT] Connected with server successfully\n"); /* Get input from the user */ printf("[CLIENT] Enter message: "); gets(client_message); /* Send the message to server */ if(send(socket_desc, client_message, strlen(client_message), 0) < 0){ printf("[CLIENT] Unable to send message\n"); return -1; } /* Receive the response from server */ if(recv(socket_desc, server_message, sizeof(server_message), 0) < 0){ printf("[CLIENT] Error while receiving server's msg\n"); return -1; } printf("[CLIENT] Server's response: %s\n",server_message); /* Close the socket */: close(socket_desc); return 0; }

Server code

#include <stdio.h> #include <string.h> #include <sys/socket.h> #include <arpa/inet.h> int main(void) { int socket_desc, client_sock, client_size; struct sockaddr_in server_addr, client_addr; char server_message[2000], client_message[2000]; /* Clean buffers and structures*/ memset(server_message, 0, sizeof(server_message)); memset(client_message, 0, sizeof(client_message)); memset(&server_addr, 0, sizeof(server_addr)); memset(&client_addr, 0, sizeof(client_addr)); /* Create socket */ socket_desc = socket(AF_INET, SOCK_STREAM, 0); if(socket_desc < 0){ printf("[SERV] Error while creating socket\n"); return -1; } printf("[SERV] Socket created successfully\n"); /* Set port and IP that we'll be listening for, any other IP_SRC or port will be dropped */ server_addr.sin_family = AF_INET; server_addr.sin_port = htons(2000); server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); /* Bind to the set port and IP */ if(bind(socket_desc, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) { printf("[SERV] Couldn't bind to the port\n"); return -1; } printf("[SERV] Binding completed successfully\n"); /* Listen for clients */ if(listen(socket_desc, 1) < 0) { printf("Error while listening\n"); return -1; } printf("[SERV] Start listening for incoming connections.....\n"); /* Accept an incoming connection from one of the clients */ client_size = sizeof(client_addr); client_sock = accept(socket_desc, (struct sockaddr*)&client_addr, &client_size); if (client_sock < 0){ printf("Can't accept\n"); return -1; } printf("[SERV] Client connected at IP: %s and port: %i\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); /* Receive message from clients. Note that we use client_sock, not socket_desc */ if (recv(client_sock, client_message, sizeof(client_message), 0) < 0){ printf("[SERV] Couldn't receive\n"); return -1; } printf("[SERV] Received message from client: %s\n", client_message); /* Write a response to the client */ strcpy(server_message, "This is the server's message."); if (send(client_sock, server_message, strlen(server_message), 0) < 0){ printf("[SERV] Can't send\n"); return -1; } /* Close the sockets */ close(client_sock); close(socket_desc); return 0; }

</spoiler>

Multiplexare I/O

Am întâlnit trei tipuri de apeluri blocante, care sunt de fapt citiri din descriptori (de sockeți sau fișiere):

  1. accept() - citire de pe socketul inactiv pe care ascultă serverul
  2. recv() / recvfrom() - citire de pe sockeți activi
  3. scanf() / gets() / read(0, ...) - citire de la tastatură.

Intalnim o problemă comună: un program se află blocat într-o citire pe un descriptor, dar primește date pe un alt descriptor.

Ce facem daca vrem sa monitorizam trei socketi in acelasi timp pentru date? Avem nevoie de un mecanism care să ne permită să citim exact de pe descriptorul pe care au venit date.

Soluția este reprezentată de funcția poll(), care ajută la controlarea mai multor descriptori (de fișiere sau sockeți) în același timp.

#include <poll.h> int poll(struct pollfd fds[], nfds_t nfds, int timeout);

Argumentele funcției select():

  1. fds - mulțimea de file descriptori monitorizați
  2. nfds - numărul de elemente din vectorul fds
  3. timeout - timpul maxim în care apelul poll() trebuie să întoarcă, exprimat în milisecunde. (o valoare negativă specifică o așteptare indefinită până la apariția unui eveniment, în timp ce o valoare nulă presupune întoarce imediată a apelului de poll()).

Structura pollfd este definită în sys/poll.h:

#include <sys/poll.h> struct pollfd { int fd; /* file descriptor */ short events; /* evenimente solicitate */ short revents; /* evenimente apărute */ };

În cadrul structurii pollfd avem:

  • events este o mască de biți în care se specifică evenimentele urmărite de poll pentru descriptorul fd:

    • POLLIN - există date ce pot fi citite
    • POLLOUT - se pot scrie date
  • revents este, de asemenea, o mască de biți completată de kernel

    • cu evenimentele apărute în momentul în care apelul se întoarce(POLLIN, POLLOUT)
    • cu valori predefinite (POLLERR, POLLHUP, POLLNVAL) pentru situații speciale.

În caz de succes, funcția returnează un număr diferit de zero reprezentând numărul de structuri pentru care revents nu e zero (cu alte cuvinte toți descriptorii cu evenimente sau erori).

Se returnează 0 dacă a expirat timpul (timeout milisecunde) și nu a fost selectat nici un descriptor.

Exemplu de folosire poll

#define MAX_PFDS 32 [...] struct pollfd pfds[MAX_PFDS]; int nfds; int listenfd, sockfd; /* listener socket; connection socket */ nfds = 0; /* read user data from standard input */ pfds[nfds].fd = STDIN_FILENO; pfds[nfds].events = POLLIN; nfds++; /* TODO ... create server socket (listener) */ /* add listener socket */ pfds[nfds].fd = listenfd pfds[nfds].events = POLLIN; nfds++; while (1) { /* server loop */ /* wait for readiness notification */ poll(pfds, nfds, -1); if ((pfds[1].revents & POLLIN) != 0) { /* TODO ... handle new connection */ } else if ((pfds[0].revents & POLLIN) != 0) { /* TODO ... read user data from standard input */ } else { /* TODO ... handle message on connection sockets */ } } [...]

Timere

În laborator ni se solicită trimiterea unui mesaj periodic. Pentru a realzia acest lucru vom folosi API-ul de timerfd din Linux.

Timerfd este un file descriptor și este creat folosind timerfd_create. Functia timerfd_settime îi setează intervalul la care să ne anunțe (1 secunda in acest caz).

#include <sys/time.h> #include <sys/timerfd.h> int timerfd; timerfd = timerfd_create(CLOCK_REALTIME, 0); struct itimerspec spec; spec.it_value.tv_sec = 1; spec.it_value.tv_nsec = 0; spec.it_interval.tv_sec = 1; spec.it_interval.tv_nsec = 0; timerfd_settime(timerfd, 0, &spec, NULL);

Daca facem read pe timerfd, acesta se va bloca până când va expira timpul setat de timerfd_settime() (în cazul acesta o secundă).

uint64_t count; read(timerfd, &count, sizeof(count));

Astfel, când read s-a deblocat, înseamnă că timpul a expirat, iar valoarea de tipul uint64_t citită va fi egală cu numărul de ori de care a expirat timmer-ul, de la ultima resetare (ex. pentru timmer-ul de o secundă, după 1 minut, valoarea va fi 60).

Exerciții

In cadrul laboratorului de astazi vom implementa o aplicatie de chat precum Whatsapp.

Descrierea scheletului

Pentru implementarea cerințelor, vom porni de la acest schelet de cod.

În scheletul de laborator găsiți implementată o aplicație client-server care implementează o funcție de chat, care funcționează astfel:

  • După pornire, serverul așteaptă conectarea a doi clienți
  • După ce ambi clienți s-au conectat, aceștia trimit mesaje către server, pentru a fi trimise către celălalt client.
  • La primirea unui mesaj, clientul afișează ce a primit.

Aplicația din schelet are câteva neajunsuri pe care le vom corecta.

Cerinte

Task 1


Note

TCP tratează informația transmisă ca pe un flux de date (octeții in ordine) și are un proces complex (cu ACK-uri, timere, etc.) care trebuie ascuns de utilizator (pentru a fi ușor de folosit). Astfel, apelul send() nu determină trimiterea imediată a unui mesaj/segment, ci face append datelor oferite la un buffer local de transmitere, trimiterea și recepționarea segmentelor de date fiind administrată de către stiva TCP/IP (implementată în kernel) în mod asincron.

La rândul său, recv() se deblochează atunci când orice cantitate de informație devine disponibilă în buffer-ul de recepție, neexistând o încadrare implicită (în mesaje) a octeților din flux.

În consecință, dacă protocolul de nivel aplicație vede transmisia ca fiind alcătuită din mesaje, atunci trebuie să ne așteptăm că este posibil să apară oricând:

  • trunchieri - din niște octeți trimiși de către o aplicație, în procesul de la distanță devine disponibilă doar o parte din ei, și totuși apelul recv() se întoarce, punând la dispoziția apelantului doar o parte din mesaj
  • concatenări - mai mult de un mesaj se citește cu un singur apel de recv().

Pentru a fi simplă identificarea mesajelor, implementarea din schelet trimite informația în calupuri de lungime fixată (sizeof(struct chat_meesage)), însă acest lucru nu este suficient deoare funcțiile recv() și send() pot recepționa / copia pentru trimitere mai puțini octeți decât am dat ca parametru.

Reimplementați funcțiile din common.h send_all() și recv_all() astfel încât să acoperiți acest neajuns.

Task 2

Alte neajunsuri ale aplicației sunt că:

  • Aceasta funcționează doar cu exact 2 clienți;
  • Clienții au o ordine fixată a comunicației (clientul x nu poate da mesaje când este rândul clientului y);
  • Ambii clienți întâi citesc un mesaj de la tastatură, apoi așteaptă să vadă ce primesc de la server. Asta face ca interactivitatea să fie afectată. Este un comportament așteptat înainte să rezolvăm task-ul 2.

Folosind API-ul de multiplexare (de exemplu, select(), poll() sau epoll()), urmăriți TODO-urile pentru a realiza o comunicare de tip chat între mai mulți clienți.


Funcționalități pentru a valida soluția:

  • serverul va accepta noi clienți în orice moment;
  • clienții vor putea comunica în orice ordine;
  • un mesaj primit de la un client va fi trimis către toți ceilalți, iar aceștia îl vor afișa imediat.

Task 3

La fiecare 4 secunde, server-ul va trimite un anunt despre abonamentele premium catre toti clientii: "Dragi clienti, pentru doar 12 lei o sa puteti trimite de 10 ori mai multe mesaje in jumatate din timp". Vom folosi un timerfd pentru a implementa aceasta functionalitate.

Lectură laborator


De parcurs înainte de laborator:


Colapsul congestiei din 1986

În octombrie 1986, a fost detectată o prăbușire a congestiei pe Internet pe o legătură de 32 kbps între campusul Universității din California, Berkeley și Laboratorul Național Lawrence Berkeley, aflat la 400 de metri distanță, în timpul căreia debitul a scăzut cu un factor de aproape 1.000, ajungând la 40 bps.

Doi ani mai târziu, Van Jacobson a implementat și publicat algoritmul de control al congestiei în versiunea Tahoe a TCP, bazată pe o idee a lui Raj Jain, K.K. Ramakrishnan și Dah-Ming Chiu. Înainte de Tahoe, existau mecanisme în TCP care împiedicau expeditorii să copleșească receptorii (Flow Control), dar nu exista niciun mecanism eficient care să împiedice expeditorii să copleșească rețeaua. Acest lucru nu a fost o problemă deoarece existau puțini gazde, până la mijlocul anilor 1980. Până în noiembrie 1986, numărul de gazde a fost estimat să fi crescut la 5.089, dar majoritatea legăturilor de bază au rămas la 50 - 56 bps (biți pe secundă) de la începutul ARPANet.

In figura de mai jos gasim reprezentare grafica a traficului care dupa un punct face colaps.

Controlul Congestiei

Am văzut în laboratoarele precedente că dimensiunea ferestrei transmițătorului era calculată în funcție de BDP. În cazul în care considerăm că dimensiunea maximă a unui segment este Maximum Segment Size (MSS), atunci am putea calcula dimensiunea optimă a ferestrei ca fiind BDP / MSS. Totuși, ce facem atunci când avem mai mulți transmițători ce împart același link?

Fie următoarea topologie în care avem 2 transmițători care împart un link către H3.

Dacă atât H1 cât și H2 ar avea un throughput de transmisie de 50 Mb/s, atunci am ajunge la 100 Mb/s pe link-ul către H3, ce are o capacitate de doar 50 Mb/s. Acest lucru va rezulta în pierderea segmentelor și retransmisia.

Pierderile apar de la faptul ca buffer-ul din router se umple. El poate trimite catre h2 cu 50 Mb/s in timp ce primeste pachete la 100 Mb/s de la h1 si h3.

Pentru a evita acest colaps al rețelei cauzat de congestie, transmițătorul va trebui să își limiteze dimensiunea ferestrei de transmisie. În acest scop, introducem Congestion Window (CWND), ce reprezintă fereastra de congestie, care este numarul de octeti pe care transmitatorul ii poate trimite fara a astepta o confirmare.

Fereastra de congestie este exprimata de obicei in octeti pentru a permite folosirea segmentelor de dimensiuni variabile de catre transmitator. Alternativ, ea poate fi exprimata in unitati, unde fiecare unitate reprezinta un segment de dimensiune maxima (MSS, sau Maximum Segment Size. In Internet MSS este in jurul 1500B).

Fereastra de congestie este actualizata dinamic de catre transmitator. Fereastra va creste atunci cand nu exista congestie, si va fi redusa atunci cand reteaua este congestionata. Valoarea minima a ferestrei este de 1MSS.

Slow Start

Algoritmul de Slow Start pornește cu o valoare a CWND = IW * MSS unde IW reprezintă Initial Window și este setat la 10, conform RFC6928. La fiecare confirmare primită, Slow Start crește fereastră cu un MSS.

CWND = CWND + MSS

Astfel, fereastră se dublează în fiecare round-trip time în timpul slow start (după 1RTT ea va ajunge 20MSS, după încă unul 40MSS, etc.) Slow start se încheie atunci când se detectează congestie în rețea, fie ca urmare a pierderii unui pachet, fie atunci când rețeaua indică explicit congestia cu ajutorul ECN (explicit congestion notification).

Trecerea la algoritmul de congestie. Introducem un prag (threshold), ssthresh după care o să trecem la utilizarea unui algoritm de congestie precum AIMD pentru actualizarea CWND. Inițial sstresh are o valoare mare, dar la fiecare timeout acesta este actualizat ssthresh = CWND/2. Atunci când CWND > sstresh transmițătorul face trecerea la AIMD.

Additive Increase, Multiplicative Decrease (AIMD)

Un posibil algoritm de evitare a congestiei este AIMD. Algoritmul crește fereastra de congestie cu un MSS per Round Trip Time (RTT).

Există două implementări posibile pentru partea de creștere a ferestrei: actualizarea ferestrei la fiecare ACK, sau o dată per RTT.

Algoritmul pentru actualizare la fiecare ACK este mai simplu de implementat pentru că nu necesită menținerea unei variabile suplimentare care să detecteze când a trecut un RTT. Să presupunem că fereastra de congestie este menținută în octeti. În acest caz, la primirea unui ACK, fereastra de congestie crește astfel.

onAck(bytes_acked - numărul de octeți confirmați de receptor) if (CWND > ssthresh) //additive increase CWND = CWND + bytes_acked * MSS / CWND else //slow start CWND = CWND + MSS

De-a lungul unui round-trip time, suma bytes_acked va fi egală cu CWND; astfel, creșterea totală va fi MSS per RTT (additive increase).

Variabila ssthresh face trecerea dintre Slow Start și Additive Increase. Atunci când conexiunea începe, Slow Start va avea valoarea MAXINT. După fiecare pierdere, ssthresh este actualizată pentru a memora o valoare "sigură" a ferestrei de congestie; atunci când valoarea CWND este sub ssthresh, creșterea este exponențială. Aceasta asigura că fereastra crește rapid la începutul conexiunii pentru a utiliza rapid capacitatea rețelei.


Atenție!

Dacă toate variabilele folosite sunt întregi, există riscul ca atunci când CWND are valori mari, creșterea bytes_acked * MSS să fie mai mică decât CWND și astfel fereastră să nu mai crească! Pentru a evita astfel de erori de rotunjire, se vor folosi valori floating point pentru CWND.


Atunci când un packet este pierdut, fereastra de congestie este redusă la jumătate (multiplicative decrease). De notat este că se va reduce fereastră o dată per loss event - chiar dacă se pierd mai multe pachete într-un RTT, fereastra este redusă o singură dată.

onLoss(detectată cu ack-uri duplicate) CWND = CWND / 2 ssthresh = CWND

Atunci când nu primim confirmarea pentru un packet și expiră timer-ul, se va execută codul de mai jos care reduce CWND și mai agresiv:

onTimeout(): ssthresh = CWND/2 CWND = 1 MSS

Comportamentul dat de AIMD de evitare a congestiei este surprins în figura de mai jos și se mai numește "dinți de fierăstrău".

Etapele TCP

În figura de mai jos este surprins comportamentul TCP ce folosește Slow Start și algoritmul AIMD de evitare a congestiei.

Animatia de mai jos prezinta comportamentul TCP. Pe măsură ce fereastra de congestie crește, viteza la care pachetele (albastre) sunt trimise crește de asemenea, până când utilizarea legăturii limită atinge 100%. Apoi, pe măsură ce rata pachetelor trimise continuă să crească, pachetele încep să se acumuleze în buffer. În cele din urmă, bufferul devine plin și routerul trebuie să renunțe la pachetele noi.

Când expeditorul ia la cunoștință de pachetul abandonat (deoarece nu primește un ACK pentru acesta), își reduce fereastra de congestie cu un factor multiplicativ. Cu o fereastră de congestie mai mică și multe pachete neconfirmate deja "în zbor", acesta trebuie să facă o pauză înainte de a putea relua transmisia, astfel încât bufferul să aibă ocazia să se golească. Odată ce trece ceva timp și mai multe dintre segmentele "în zbor" sunt confirmate, expeditorul poate relua transmisia și poate începe să-și mărească din nou fereastra de congestie. Acest proces continuă pe parcursul întregii durate de viață a fluxului, ducând la un model clasic de "dinti de fierăstrău".

Iperf

Iperf este unul dintre cele mai populare programe folosite pentru măsurarea performanței în networking.

Pentru a porni iperf în modul server TCP vom folosi:

iperf -s

Pentru a porni clienți TCP folosind iperf, vom folosi:

iperf -c $IP_SERVER

Serverul va afișa throughput-ul măsurat pentru fiecare conexiune de la client. Mai jos gășiți un exemplu de output.

> iperf -s ------------------------------------------------------------ Server listening on TCP port 5001 TCP window size: 85.3 KByte (default) ------------------------------------------------------------ [ 1] local 127.0.0.1 port 5001 connected with 127.0.0.1 port 57374 (icwnd/mss/irtt=215/22016/23) [ ID] Interval Transfer Bandwidth [ 1] 0.00-2.00 sec 14.3 GBytes 61.3 Gbits/sec
❯ iperf -c 127.0.0.1 -t 2 ------------------------------------------------------------ Client connecting to 127.0.0.1, TCP port 5001 TCP window size: 2.50 MByte (default) ------------------------------------------------------------ [ 1] local 127.0.0.1 port 57374 connected with 127.0.0.1 port 5001 (icwnd/mss/irtt=213/21845/52) [ ID] Interval Transfer Bandwidth [ 1] 0.00-2.01 sec 14.3 GBytes 61.1 Gbits/sec

Iperf poate funcționa și cu UDP, trebuie doar folosit flag-ul. -u. Pentru a seta rata de transmisie (throughput) în UDP vom folosi: -b (biți/s).

Socket Stats

Socket stats (ss) este un utilitar ce afișează pentru fiecare conexiune TCP parametrii din transmission control block (TCB) precum CWND, thrghouput etc.

> ss -tin State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess ESTAB 28 1475512 192.168.1.100:59480 172.16.0.100:5001 cubic wscale:9,9 rto:414 rtt:213.93/0.851 ato:40 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:187 ssthresh:158 bytes_sent:1330772 bytes_acked:1061445 bytes_received:28 segs_out:923 segs_in:263 data_segs_out:920 data_segs_in:1 send 10125779bps lastsnd:1 lastrcv:886 lastack:1 pacing_rate 12150896bps delivery_rate 9562208bps delivered:735 busy:890ms rwnd_limited:104ms(11.7%) unacked:186 rcv_space:14480 rcv_ssthresh:42242 notsent:1206184 minrtt:4.052

Exerciții

Scheletul laboratorului se regaseste la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md. În continuare, vom folosi Mininet pentru a simula o topologie formată din 3 host-uri conectate la un router: sudo python3 topo.py.

Astăzi vom folosi matplotlib pentru a desena grafice.

sudo apt install python3-pip sudo pip3 install matplotlib

Astazi vom studia comportamentul TCP in mai multe situatii, vom urmarii live cum TCP actualizeaza CWND si cum acest lucru afecteaza throughput-ul.

Vă punem la dispoziție un script de Python, run_client.py, care rulează un client de iperf și desenează un grafic interactiv cu throughput-ul și congestion window-ul (cwnd) clientului. Adresa IP a lui H2 este ip address show -> 172.16.0.100. Din topo.py puteți modifica parametrii link-urilor.

1. Pe h2 rulați un server de iperf. Pe h1 rulați scriptul run_client.py și urmăriți graficul. După 30 de secunde deschideți pe h3 un al doilea client de iperf. Urmăriți cele două grafice, ce se întâmplă cu throughput-ul și cwnd? Cum sunt impartite intre h1 si h3? Modificati valorile bandwidth-ului la 30Mbps, se modifica comportamentul?

2. Vom reproduce scenariul anterior, doar că pe H1 vom rula scriptul run_client.py, iar pe h3 vom porni n instanțe de clienți folosind iperf -c 172.16.0.100. Ce putem spune că se întâmplă în acest caz?

3. De data aceasta vom rula două servere iperf pe h2, unul de UDP și unul de TCP. Pe h1 vom rula run_client.py, iar pe h3 vom rula un client de UDP cu un bandwidth (-b) cu valori cuprinse între 1 Mb - 10 Mb. Ce observăm?

4. Parcurgeti subsectiunea Modeling TCP congestion control. Vom modifica delay-ul din topo.py pe link-ul dintre router si h3 sa fie 5ms. Cum va fi impartit throughput-ul intre h1 si h3?

Hint: CWND=83p=kp și Throughput=32MSSRTTp

5. Vom face un client de iperf. Acesta este un simplu program ce se conectează la un server, trimite un număr de bytes pentru t secunde și la final afișează throughput-ul.

Lectură laborator


De parcurs înainte de laborator:


Protocolul HTTP

Miliarde de imagini JPEG, pagini HTML, fișiere text, filme în format MPEG, fișiere audio WAV, applet-uri Java și multe altele sunt accesate pe internet în fiecare zi. HTTP este protocolul responsabil cu mutarea acestora rapid, convenabil și fiabil de la serverele web din întreaga lume la browserele web ale utilizatorilor. Deoarece HTTP, este un protocol peste TCP, datele transmisie nu vor fi deteriorate sau amestecate sau pierdute în timpul tranmisiei de date.

HTTP (HyperText Transfer Protocol) este un protocol de nivel 7 din stiva OSI (aplicatie) folosit pentru transferul informatiilor in internet. Este un protocol care opereaza peste date de tip ASCII.

La baza protocolului HTTP stau conceptele de cerere si raspuns. In cazul comunicatiei HTTP, o entitate inainteaza o cerere si cealalta trebuie, obligatoriu, sa ofere un raspuns.

Probabil că utilizați clienți HTTP în fiecare zi. Cel mai comun client este un browser web (de ex. Google Chrome, Mozilla, Internet Explorer, Safari, etc.). Browserele web sunt entitățiile care solicită artefacte HTTP de la servere și le afișează pe ecran. HTTP functioneaza implicit peste portul 80. Versiunea securizata de HTTP, HTTPS, functioneaza implicit peste portul 443. Un server, insa, poate fi configurat sa asculte cereri HTTP pe orice port disponibil.

Cereri HTTP

Cu totii sunteti familiari cu acest format:

Exemplul de mai sus cuprinde:

  • versiunea protocolului
  • host-ul interlocutorului
  • calea de pe serverul interlocutorului unde se va desfasura actiunea
  • parametri aditionali de cerere (optionali)

Ce este prezentat in poza nu este o cerere HTTP, ci preambul unei cereri HTTP. De fapt, in momentul in care se da enter, browserul (sau orice alt client) creaza, bazat pe informatiile oferite, cererea HTTP efectiva.

Formatul cererii este urmatorul:

METODA CALE VERSIUNE_PROTOCOL\r\n Host: HOST\r\n Header1: Valoare Header1\r\n Header2: Valoare Header2\r\n ... Cookie: cheie1=valoare1; cheie2=valoare2; ...; cheieN=valoareN\r\n \r\n DATA

Linia de start contine 3 elemente.

Primul element este metoda folosita. Metodele HTTP sunt verbe ce descriu actiunea ce va fi efectuata asupra entitatii catre care se transmite cererea. Cele mai des utilizate cereri sunt:

  • GET - interogare de resurse
  • POST - aduagare de resurse. De obicei are si date atasate.
  • PUT - modificare de resurse. De obicei are si date atasate.
  • DELETE - stergere de resurse

Al doilea element este reprezentat de calea si parametrii de cerere (daca exista) unde se va actiona asupra resursei, pe server. In cazul in care exista parametri de cerere, acestia trebuie separati de restul caii prin ?.

Al treilea element este reprezentat de versiunea protocolului de HTTP folosita. Implicit, din motive de securitate, este folosit HTTPS. Pentru varianta ne-securizata, ultima versiune este HTTP/1.1.

A doua linie descrie host-ul entitatii unde va fi transmisa cererea. Host-ul poate sa fie atat un ip cat si un domeniu.


Note

Host-ul este, de fapt, tot un header. Pentru a omite aceasta linie este necesar sa puneti host-ul in cale, exact cum se scrie in browser. Totusi, este indicat sa tratati host-ul ca linie separata.


Headerele sunt scrise cate unul pe rand. Acestea sunt folosite pentru a transmite informatii aditionale catre interlocutor. Headerele se impart in 3 categorii:

  • Request Headers - descriu modul in care se face cererea si cum se poate raspunde la ea. Exemplu: User-Agent
  • General Headers - descriu informatii cu caracter general care tin de comunicare. Exemplu: Keep-Alive
  • Entity Headers - descriu informatii despre datele (daca exista) atasate cererii. Exemplu: Content-Type si Content-Length

Note

Daca exista data atasata cererii, trebuie specificate, obligatoriu, cele doua headere Content-Type si Content-Length


Cookies

Cookies sunt scrise inlantuit, delimitat de punct si virgula (mai putin ultima). Implicit, comunicarea HTTP este considerata stateless. Nu se poate face corelatie intre oricare doua cereri succesive. Cookies retin bucati de informatie trimise de la server catre client, pentru a putea fi refolosite in cereri ulterioare.


Note

Cookiurile sunt artefacte care se salvează doar la Client.

Inaintea datelor (sau la finalul cererii, daca nu exista date) se pune intotdeauna \r\n\


Data variaza in functie de tipul de data transmis. Cele mai des intalnite tipuri de date transmise sunt:

  • text/html - De exemplu, pagini HTML
  • application/x-www-form-urlencoded - Date de forma key1=value1&key2=value2&...&keyN=valueN. Datele sunt inlantuite prin "&"
  • application/json - Date de forma JSON (Javascript Object Notation). Folosite des in interactiunea cu API-uri
  • multipart/form-data - Date binare, de exemplu, fisiere

Pe baza informatiilor prezentate mai sus, o varianta simplificata a cererii catre facebook, din exemplu, ar arata asa:

GET /search/top/?q=programare%20%web%202020 HTTPS\r\n Host: facebook.com\r\n User-Agent: Mozilla/5.0\r\n Connection: keep-alive\r\n Cookie: c_user=XXXXXXXXXX; presence=XXXXXXX\r\n \r\n

Exemplu foarte simplu de POST.

POST /test HTTP/1.1 Host: foo.example Content-Type: application/x-www-form-urlencoded Content-Length: 27 field1=value1&field2=value2

Note

Este obligatoriu sa puneti \r\n la finalul fiecarui rand din cerere, cu exceptia datelor atasate.


Un exemplu de implmentare a unei cereri HTTP de tip GET pe baza codului sursă din skeletpoate fi gasit mai jos.

message = compute_get_request(SERVERADDR, "/api/v1/dummy", NULL, NULL, 0); send_to_server(sockfd, message); response = receive_from_server(sockfd); printf("%s\n", response);

Raspunsuri HTTP

Orice cerere HTTP este urmata de un raspuns. Raspunsurile seamana cu cererile din punct de vedere al organizarii. Formatul este urmatorul:

PROTOCOL_VERSION STATUS_CODE STATUS_TEXT\r\n Header1: Valoare Header1\r\n Header2: Valoare Header2\r\n ... HeaderN: Valoare HeaderN\r\n Set-Cookie: cheie1=valoare1\r\n Set-Cookie: cheie2=valoare2\r\n ... Set-Cookie: cheieN=valoareN\r\n \r\n DATA

Linia de start contine 3 elemente.

Pentru implemenatrea unei răspuns de tip HTTP POST pe baza codului skelet, trebuie aplelată funcția compute_post_request cu parametrul content_type = application/x-www-form-urlencoded.

Primul element este reprezentat de versiunea protocolului de HTTP folosit pentru a se raspunde.

Al doilea element este reprezentat de statusul raspunsului. Statusul este corelat de reusita, respectiv esecul cererii si de ce s-a intamplat pe entitatea catre care s-a trimis cererea. Exemplu de statusuri des intalnite:

  • 200 - OK
  • 201 - Created
  • 204 - No Content
  • 400 - Bad Request
  • 401 - Unauthorized
  • 403 - Forbidden
  • 404 - Resource Not Found
  • 500 - Internal Server Error

Al treilea element descrie textul care insoteste statusul.

Headerele urmeaza aceeasi structura si descriu acelasi lucru ca si in cazul cererilor.

Cookies sunt setate cate una pe linie. In afara de cheie=valoare, acestea mai au o serie de atribute atasate, precum secure, httpOnly, domain.

Data urmeaza aceeasi structura ca si in cazul cererilor.

Sesiune si autentificare

O sesiune este definită ca o serie de solicitări legate de browser care provin de la același client într-o anumită perioadă de timp. Urmărirea sesiunii leagă împreună o serie de solicitări de browser - gândiți-vă la aceste solicitări ca pagini - care pot avea o anumită semnificație în ansamblu, cum ar fi o aplicație pentru coșul de cumpărături.

Autentificarea de bază HTTP este o metodă simplă de autentificare pentru client prin care acesta furniza un nume de utilizator și o parolă atunci când efectuează o solicitare către server. Acesta este cel mai simplu mod posibil de a impune controlul accesului, deoarece nu necesită module cookie, sesiuni sau orice altceva. Pentru a utiliza acest lucru, clientul trebuie să trimită antetul de autorizare împreună cu fiecare solicitare pe care o face.

Implementarea mecanismului de autentificare folosind codul skelet implică construirea unui mesaj de tip POST care include cei doi parametrii nume și parolă sub forma unui vectori de stringuri.

Suportul de laborator

Va oferim aici un cod sursă schelet pentru realizarea unui client HTTP scris in C. Aveti deja implementata partea de realizare de conexiune, partea de trimitere, respectiv receptionare bytes de la server in helpers.c si buffer.c.

Fisierele in care voi veti lucra sunt client.c si requests.c. In requests va trebui sa implementati scrierea cererilor de tip GET si POST si in client trebuie sa trimiteti cererea proaspat compusa catre destinatie.

Veti interactiona cu doua servere:

  • Serverul principal scris de noi aflat la adresa 34.254.242.81 pe portul 8080
  • API-ul oferit de Openweather Map aflat la adresa api.openweathermap.org pe portul 80

Exerciții

Pornind de la codul disponibil aici, aveți de implementat următoarele cerințe:

1. Implementati folosind instrucțiunile din îndrumarul de laborator o cerere dummy de tip GET pentru adresa /api/v1/dummy de la serverul principal.


Note

Gasiti aici un exemplu de request GET.


2. Implementati folosind instrucțiunile din îndrumarul de laborator o cerere dummy de tip POST pentru adresa /api/v1/dummy de la serverul principal cu cu orice conținut pentru date de forma application/x-www-form-urlencoded.


Note

Gasiti aici o captura cu mesaje de tip POST. Mai multe detalii despre POST gasiti aici


3. Ne propunem implementarea unei mecanims de autentificare. Implementați folosind instrucțiunile din îndrumarul de laborator o cerere de tip POST pentru adresa /api/v1/auth/login de pe serverul principal folosind username student si password student. Similar cu task-ul precedent datele trebuie să fie de forma application/x-www-form-urlencoded.

4. Folosind cookie-ul obtinut la pasul precedent, care poate fi hardcodat, implmentați o cerere de tip GET către adresa /api/v1/weather/key a serverului principal pentru a obține un cheia cu care vom obține informații despre vreme de la api.openweathermap.org.


Note

Gasiti aici un exemplu de mesaj GET cu cookie.


5. Folosind cheia obținută la exercițiul anterior, implmentați o cerere de tip GET la serverul Openweather Map pentru a obține datele despre vreme, specificând un set de coordonate (latitudine, longitudine) la alegere.


Note

Mai multe informații despre funcționarea API-ului puteți găsi aici.


6. Cu date obținute la punctul precedent, implementați o cerere de tip POST la serverul 34.254.242.81, calea /api/v1/weather/{latitudine}/{longitudine} (ex. /api/v1/weather/44.7398/22.2767) pentru verificare.

Pentru acest task, pentru identificarea mai facilă a începutului payload-ului, puteți să țineți cont de faptul că datele servite de serverul Openweather Map încep cu o acoladă ({), fiind un obiect în formatul JSON și nu vor apărea alte acolade în antetul răspunsului. Desigur, nu este obligatoriu să vă folosiți de asta, parsarea răspunsului HTTP fiind o alternativă cel puțin la fel de bună.

7. Implementați o cerere de tip GET către serverul principal pentru efectuarea delogări (LogOut) la /api/v1/auth/logout.

Bonus

  1. Implementați mecanismul necesar de păstrare a cookies-urilor, astfel încât dacă un client deja autentificat încearcă să facă login, el să primească un mesaj de forma "Already logged in!".
  2. Folositi Postman pentru a testa comenzile HTTP pe care le-ati implementat la laborator. Analizati cererile si raspunsurile primite si identificati elementele din protocol prezentate la curs si laborator.

Lectură laborator


De citit înainte de laborator:

Lectură video

Lectură opțională


Obiective


În urma parcurgerii acestui laborator, studentul va fi capabil să:

  • diferențieze și utilizeze două protocoale pentru citirea poștei electronice;
  • folosească protocolul pentru trimiterea de mesaje și atașamente prin poșta electronică;
  • scrie un client simplu de e-mail;
  • opereze cu ierarhia spațiilor de nume și să identifice tipurile de domenii și subdomenii;
  • folosească algoritmul de interogare utilizat de DNS;
  • identifice tipurile de resurse pentru diverse domenii și clasele acestora;
  • folosească un set minimal de funcții pentru aflarea informațiilor unui sistem gazdă.

Protocolul DNS

DNS folosește în general protocolul UDP pe portul 53, dar, în cazul răspunsurilor de dimensiuni mai mari sau pentru operații ca transferul de zone, se utilizează și TCP. Mai recent, s-a introdus și DNS over HTTPS (sau DoH, descris în RFC 8484), care presupune realizarea de cereri DNS peste HTTPS din motive de securitate.

Spațiul de nume

DNS organizează numele resurselor într-o ierarhie de domenii. Un domeniu reprezintă o colecție de sisteme gazdă care au unele proprietăți în comun, cum ar fi faptul că toate aparțin unei aceleiași organizații sau faptul că toate sunt situate geografic în același perimetru.

Fiecare domeniu este partiționat în subdomenii și acestea sunt la rândul lor, partiționate, ș.a.m.d. Toate aceste domenii pot fi reprezentate ca un arbore, după cum se poate vedea mai sus. Frunzele arborelui reprezintă domenii care nu au subdomenii, dar care conțin totuși sisteme. Un domeniu frunză poate conține de la un singur sistem gazdă până la mii de sisteme gazdă.

Domeniile de pe primul nivel se împart în două categorii: generice (gTLD-uri) și de țări (ccTLD-uri). Domeniile generice inițiale erau com (comercial), edu (instituții educaționale), gov (guvernul SUA), int (organizații internaționale), mil (forțele armate ale SUA) și org (organizații nonprofit). În ziua de astăzi, restricțiile legate de astfel de domenii sunt mult mai mici, existând astfel peste 1200 de domenii top-level generice. Domeniile de țări includ o intrare pentru fiecare țară, după cum se definește în ISO 3166. Fiecare domeniu este denumit de calea în arbore până la rădăcină. Componentele sunt separate prin punct. Astfel, departamentul de Calculatoare de la UPB poate fi cs.pub.ro în loc de numele în stil UNIX /ro/pub/cs.

Numele de domenii pot fi absolute sau relative. Un nume absolut de domeniu (FQDN - fully qualified domain name) este un nume de domeniu care nu permite nici o ambiguitate cu privire la locația relativă la rădăcina arborelui de nume de domenii. Astfel de nume absolute de domenii se termină cu punct (de exemplu cs.pub.ro.). În contrast, un nume relativ de domeniu este un nume care are sens numai relativ la un anume domeniu DNS (altul decât cel rădăcină).

Numele de domenii nu fac distincție între litere mici și litere mari, edu sau EDU însemnând practic același lucru. Componentele numelor pot avea o lungime de cel mult 64 de caractere, iar întreaga cale de nume nu trebuie să depășească 255 de caractere.

Fiecare domeniu controlează cum sunt alocate domeniile de sub el. De exemplu, Japonia are domeniile ac.jp și co.jp echivalente cu edu și com. Olanda nu face nicio distincție și pune toate organizațiile direct sub nl. Pentru a crea un nou domeniu, se cere permisiunea domeniului în care va fi inclus. De exemplu, dacă un grup PCom de la CS dorește să fie cunoscut ca pcom.cs.pub.ro, acesta are nevoie de permisiunea celui care administrează cs.pub.ro. Similar, o nouă universitate care dorește obținerea unui domeniu va trebui să ceară permisiunea administratorului domeniului edu. În acest mod, sunt evitate conflictele de nume și fiecare domeniu poate ține evidența tuturor subdomeniilor sale. Odată ce un nou domeniu a fost creat și înregistrat, el poate crea subdomenii, fără a cere permisiune de la cineva din partea superioară a arborelui.

Algoritmul de interogare

Conceptele cu care DNS lucrează sunt:

  1. Servere DNS - Stații care rulează programe de tip server de DNS ce conțin informații asupra bazelor de date DNS și despre structura numelor de domenii.
  2. Resolvere DNS - Programe care folosesc cereri DNS pentru interogarea unor servere DNS.

Modul în care se derulează procesul de interogare DNS este cel din figura de mai jos.

Înregistrări de resurse

Fiecărui domeniu, fie că este un singur calculator gazdă, fie un domeniu de nivel superior, îi poate fi asociată o mulțime de înregistrări de resurse (resource records sau RR-uri). Pentru un singur sistem gazdă, cea mai obișnuită înregistrare de resursă este chiar adresa IP, dar există multe alte tipuri.

Atunci când procedura resolver trimite un nume de domeniu DNS, ceea ce va primi ca răspuns sunt înregistrările de resurse asociate acelui nume. Astfel, adevărata funcție a DNS este să realizeze corespondența dintre numele de domenii și înregistrări de resurse.

O înregistrare de resursă este un 5-tuplu. Cu toate că, din rațiuni de eficiență, înregistrările de resurse sunt codificate binar, în majoritatea expunerilor ele sunt prezentate ca text ASCII, câte o înregistrare de resurse pe linie. Formatul utilizat este <Nume_domeniu, Timp_de_viață, Tip, Clasă, Valoare>:

  • Câmpul Nume_domeniu precizează domeniul căruia i se aplică această înregistrare. În mod normal, există mai multe înregistrări pentru fiecare domeniu, și fiecare copie a bazei de date păstrează informații despre mai multe domenii. Acest câmp este utilizat cu rol de cheie de căutare primară pentru a satisface cererile. Ordinea înregistrărilor în baza de date nu este semnificativă. Când se face o interogare despre un domeniu, sunt returnate toate înregistrările care se potrivesc cu clasa cerută.
  • Câmpul Timp_de_viață dă o indicație despre cât de stabilă este înregistrarea.
  • Câmpul Tip precizează tipul înregistrării. Cele mai importante tipuri sunt prezentate în tabelul de mai jos.
TipSemnificațieValoare
SOAStart autoritateParametri pentru această zonă
AAdresa IPv4 a unui sistem gazdăÎntreg pe 32 de biți
AAAAAdresa IPv6 a unui sistem gazdăÎntreg pe 128 de biți
MXSchimb de poștăPrioritate, domeniu dispus să accepte poștă electronică
NSServer de numeNumele serverului pentru acest domeniu
CNAMENume canonicNumele domeniului
PTRPointerPseudonim pentru adresă IP
HINFODescriere sistem gazdăUnitate centrală și sistem de operare în ASCII
TXTTextText ASCII neinterpretat

O înregistrare SOA furnizează numele sursei primare de informație despre zona serverului de nume, adresa de e-mail a administratorului, un identificator unic si diverși indicatori și contoare de timp.

Cel mai important tip de înregistrare este înregistrarea A (adresă). Ea păstrează adresa IP de 32 de biți a sistemului gazdă. Următoarea ca importanță este înregistrarea MX. Aceasta precizează numele domeniului pregătit să accepte poștă electronică pentru domeniul specificat. Înregistrările specifică numele serverului.

Un exemplu de informație ce se poate găsi în baza de date DNS a unui domeniu este următorul:

; Authoritative Information on physics.groucho.edu. @ IN SOA niels.physics.groucho.edu. janet.niels.physics.groucho.edu. { 1999090200 ; serial no 360000 ; refresh 3600 ; retry 3600000 ; expire 3600 ; default ttl }; ; Name servers IN NS niels IN NS gauss.maths.groucho.edu. gauss.maths.groucho.edu. IN A 149.76.4.23 ; ; Theoretical Physics (subnet 12) niels IN A 149.76.12.1 IN A 149.76.1.12 name server IN CNAME niels otto IN A 149.76.12.2 quark IN A 149.76.12.4 down IN A 149.76.12.5 strange IN A 149.76.12.6 ... ; Collider Lab. (subnet 14) boson IN A 149.76.14.1 muon IN A 149.76.14.7 bogon IN A 149.76.14.12 ...

Servere de nume

Teoretic, un singur server de nume poate conține întreaga bază de date DNS și poate să răspundă tuturor cererilor. În practică, acest server poate fi atât de încărcat încât să devina de neutilizat. Pentru a evita probleme asociate cu existența unei singure surse de informație, spațiul de nume DNS este împărțit în zone care nu se suprapun. O posibilă astfel de împărțire este cea de mai jos.

Fiecare astfel de zonă conține câte o parte a arborelui, precum și numele serverelor care păstrează informația autoritară despre acea zonă. În mod normal, o zonă va avea un server de nume primar, care preia informația dintr-un fișier de pe discul propriu, și unul sau mai multe servere de nume secundare, care iau informația de la serverul primar. Pentru a îmbunătăți fiabilitatea, unele servere pentru o zonă pot fi plasate chiar în afara zonei.

Plasarea limitelor unei zone este la latitudinea administratorului ei. Această decizie este luată în mare parte pe baza numărului de servere de nume care se doresc a se folosi, și a locației acestora. Atunci când un resolver are o cerere referitoare la un nume de domeniu, el transferă cererea unuia din serverele locale de nume. Dacă domeniul este sub jurisdicția serverului de nume, el va întoarce înregistrări de resurse autoritare. O înregistrare autoritara (authoritative record) este cea care vine de la autoritatea care administrează înregistrarea, și astfel este întotdeauna corectă. Înregistrările autoritare se deosebesc de înregistrările din memoria cache, care pot fi expirate.

Dacă totuși domeniul se află la distanță, iar local nu este disponibilă nici o informație despre el, atunci serverul de nume trimite un mesaj de cerere către serverul de nume de pe primul nivel al domeniului solicitat. De menționat că metoda de interogare este recursivă (recursive query), deoarece fiecare server care nu are informația cerută o caută în altă parte și raportează.

API DNS

gethostbyname() și gethostbyaddr()

Până de curând, pentru a afla un nume pe baza unei adrese IP și o adresă pe baza unui nume, se foloseau funcțiile gethostbyname() și gethostbyaddr(), împreună cu structura hostent. Între timp, acest API pentru DNS a fost scos din uz.

getaddrinfo()

#include <sys/types.h> #include <sys/socket.h> #include <netdb.h> int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res);

Funcția getaddrinfo() primește informații despre numele unei gazde și al unui serviciu Internet, și returnează adresa sau adresele corespunzătoare. Parametrul node reprezintă numele simbolic (sub forma unui șir de caractere) al mașinii căreia vrem sa-i aflăm adresa (de exemplu, node poate fi “www.google.com”). Mai poate de asemenea fi reprezentat ca un șir care conține o adresă IPv4 sau IPv6.

Parametrul service specifică portul returnat în output, și poate fi pus pe NULL (caz în care portul din output rămâne neinițializat) sau poate fi dat ca un nume de serviciu (de exemplu, “http”) sau ca o valoare numerică (“80”).

Parametrul hints reprezintă criterii pentru filtrarea adreselor întoarse de apelul funcției getaddrinfo(). Este de tipul struct addrinfo, definit mai jos:

struct addrinfo { int ai_flags; int ai_family; int ai_socktype; int ai_protocol; socklen_t ai_addrlen; struct sockaddr *ai_addr; char *ai_canonname; struct addrinfo *ai_next; };

În cazul în care se dorește filtrarea, se pot completa unul sau mai multe din următoarele câmpuri (restul punându-se pe 0):

  • ai_family - se specifică familia de adrese pentru valorile returnate, putând fi setată ca AF_INET (pentru IPv4), AF_INET6 (pentru IPv6) sau AF_UNSPEC (pentru ambele)
  • ai_socktype - se filtrează după tipul de socket (SOCK_DGRAM sau SOCK_STREAM, de exemplu)
  • ai_protocol - se specifică protocolul setat în adresele returnate de funcția getaddrinfo()
  • ai_flags - se pot seta o serie de flag-uri.

În final, rezultatul este pus în parametrul res, fiind reprezentat ca o listă înlănțuită de structuri de tipul addrinfo, care se parcurge prin intermediul câmpului ai_next. Din câmpul ai_addr al rezultatului, se pot citi informațiile despre adresa și portul stației gazdă căutate (prin cast la struct sockaddr_in, de exemplu).

În caz de succes, funcția întoarce 0, iar în caz de eroare întoarce o valoare negativă, care poate fi interpretată prin intermediul funcției gai_strerror():

const char *gai_strerror(int errcode);

Important

Parametrul res este alocat de către funcția getaddrinfo(), însă el trebuie dezalocat explicit de către utilizator prin intermediul funcției freeaddrinfo():

void freeaddrinfo(struct addrinfo *res);

Pentru a afișa adresa IP (v4 sau v6) corespunzătoare numelui simbolic din parametrul node, se poate utiliza funcția inet_ntop():

#include <arpa/inet.h> const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

Primul parametrul specifică familia de protocoale (AF_INET sau AF_INET6), al doilea parametru reprezintă structura de adresă (adică, de exemplu, câmpurile sin_addr sau sin6_addr din structurile sockaddr_in pentru IPv4 sau sockaddr_in6 pentru IPv6), al treilea parametru reprezintă un șir de caractere unde va fi scrisă adresa sub formă de string, iar ultimul parametru reprezintă dimensiunea șirului de caractere în octeți. Valoarea de retur a funcției este un pointer la un șir de caractere identic cu cel din parametrul dst, sau NULL în caz de eroare.

getnameinfo()

#include <sys/socket.h> #include <netdb.h> int getnameinfo(const struct sockaddr *addr, socklen_t addrlen, char *host, socklen_t hostlen, char *serv, socklen_t servlen, int flags);

Funcția getnameinfo() realizează operația inversă față de getaddrinfo(). Mai precis, primește o adresă și returnează numele simbolic și serviciul specifice adresei respective. Primii doi parametri reprezintă adresa IP (v4 sau v6) care este căutată. Se trimit structuri specifice protocolului dorit (sockaddr_in sau sockaddr_in6) și dimensiunea lor. Rezultatele apelului sunt puse în șirurile de caractere host și serv, care sunt alocate de către utilizator. Parametrii hostlen și servlen reprezintă dimensiunile celor două șiruri de caractere.

Funcția returnează 0 dacă s-a reușit cererea DNS, sau o valoare negativă interpretată cu gai_strerror() în caz contrar.

Cereri DNS în terminalul Linux

În Linux, pentru a obține adresele IP ale unei gazde, putem folosi unul din utilitarele host sau nslookup:

$ host google.com google.com has address 172.217.20.14 google.com has IPv6 address 2a00:1450:400d:804::200e google.com mail is handled by 30 alt2.aspmx.l.google.com. google.com mail is handled by 50 alt4.aspmx.l.google.com. google.com mail is handled by 20 alt1.aspmx.l.google.com. google.com mail is handled by 10 aspmx.l.google.com. google.com mail is handled by 40 alt3.aspmx.l.google.com. $ nslookup google.com Server: 192.168.100.1 Address: 192.168.100.1#53 Non-authoritative answer: Name: google.com Address: 172.217.20.14

Pentru aflarea unui nume pe baza unei adrese IP, se pot folosi tot host sau nslookup:

$ host 8.8.8.8 8.8.8.8.in-addr.arpa domain name pointer dns.google. $ nslookup 8.8.8.8 8.8.8.8.in-addr.arpa name = dns.google. Authoritative answers can be found from: in-addr.arpa nameserver = a.in-addr-servers.arpa. in-addr.arpa nameserver = b.in-addr-servers.arpa. in-addr.arpa nameserver = c.in-addr-servers.arpa. in-addr.arpa nameserver = e.in-addr-servers.arpa. in-addr.arpa nameserver = d.in-addr-servers.arpa. in-addr.arpa nameserver = f.in-addr-servers.arpa.

Un exemplu de output tcpdump pentru comanda host este mai jos (unde 53 este portul implicit pentru DNS):

$ sudo tcpdump port 53 tcpdump: data link type PKTAP tcpdump: verbose output suppressed, use -v or -vv for full protocol decode listening on pktap, link-type PKTAP (Apple DLT_PKTAP), capture size 262144 bytes 08:12:17.446368 IP 192.168.100.4.63991 > 192.168.100.1.domain: 31461+ A? google.com. (28) 08:12:17.450412 IP 192.168.100.1.domain > 192.168.100.4.63991: 31461 1/0/0 A 172.217.16.110 (44) 08:12:18.455598 IP 192.168.100.4.58146 > 192.168.100.1.domain: 4039+ AAAA? google.com. (28) 08:12:18.463761 IP 192.168.100.1.domain > 192.168.100.4.58146: 4039 1/0/0 AAAA 2a00:1450:400d:803::200e (56) 08:12:19.467330 IP 192.168.100.4.53016 > 192.168.100.1.domain: 58519+ MX? google.com. (28) 08:12:19.514778 IP 192.168.100.1.domain > 192.168.100.4.53016: 58519 5/0/0 MX alt3.aspmx.l.google.com. 40, MX alt4.aspmx.l.google.com. 50, MX aspmx.l.google.com. 10, MX alt1.aspmx.l.google.com. 20, MX alt2.aspmx.l.google.com. 30 (136)

Utilitarul dig

dig (Domain Information Groper) este un utilitar Linux care interoghează servere de nume și afișează rezultatele într-o varietate de forme.

Înainte a vedea cum funcționează dig, este util să observăm formatul unui pachet DNS, prezentat mai jos (formatul fiecărei secțiuni se găsește detaliat în RFC 1035):

+---------------------+ | Antet | +---------------------+ | Întrebare | întrebarea pentru serverul de nume +---------------------+ | Răspuns | RR-uri care răspund la întrebare +---------------------+ | Autoritate | RR-uri care indică o autoritate +---------------------+ | Adițional | RR-uri care conțin informație adițională +---------------------+

Pentru a interoga o singură gazdă, comanda de dig arată în felul următor:

$ dig google.com

Output-ul obținut se mapează pe structura unui răspuns standard DNS, cu mențiunea că unele din RR-uri (de exemplu, cele adiționale sau de autoritate) pot să lipsească. Prima parte a output-ului conține informații despre versiunea de dig folosită și opțiunile alese:

; <<>> DiG 9.11.3-1ubuntu1.7-Ubuntu <<>> google.com ;; global options: +cmd

Mai departe, urmează desfășurarea răspunsului primit de la server-ul DNS, începând cu antetul:

;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 35678 ;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 13, ADDITIONAL: 1

În continuare, se afișează partea de întrebare (în exemplul nostru, pentru o adresă IPv4, adică A):

;; QUESTION SECTION: ;google.com. IN A

După întrebare, urmează RR-urile de răspuns (în cazul nostru, este unul singur, adică adresa IP a gazdei date):

;; ANSWER SECTION: google.com. 300 IN A 172.217.16.142

Mai departe, secțiunea cu RR-uri de autoritate ne spune ce servere DNS pot să ne ofere răspunsuri autoritare la cererile noastre:

;; AUTHORITY SECTION: . 214056 IN NS c.root-servers.net. . 214056 IN NS b.root-servers.net. . 214056 IN NS a.root-servers.net. . 214056 IN NS d.root-servers.net.

În continuare, ar putea urma secțiunea de RR-uri adiționale (care ar putea include, de exemplu, adresele serverelor de nume autoritare din secțiunea precedentă), iar la final ni se oferă statistici despre cerere:

;; Query time: 37 msec ;; SERVER: 172.16.10.254#53(172.16.10.254) ;; WHEN: Thu Apr 09 07:32:54 EEST 2020 ;; MSG SIZE rcvd: 266

Așa cum se poate observa și mai sus, o cerere implicită de dig este realizată cu tipul A (adresă IPv4). Dacă se doresc altfel de cereri, acest lucru se poate specifica la rulare, imediat după numele gazdei. Pe lângă înregistrările propriu-zise, putem folosi și wildcard-ul ANY, care ne interoghează după toate tipurile de RR-uri:

$ dig ANY google.com [...] ;; ANSWER SECTION: google.com. 283 IN AAAA 2a00:1450:400d:805::200e google.com. 39 IN SOA ns1.google.com. dns-admin.google.com. 305440781 900 900 1800 60 google.com. 25 IN A 172.217.20.14 google.com. 26083 IN NS ns3.google.com. [...]

Dacă nu ne interesează tot output-ul de mai sus și vrem să afișăm doar adresa IP (v4 sau v6), putem folosi opțiunea +short:

$ dig A google.com +short 172.217.20.14 $ dig AAAA google.com +short 2a00:1450:400d:803::200e

Dacă dorim să nu afișăm vreuna din secțiunile de răspuns ale dig, putem alege din opțiunile de mai jos:

  • +nocomments – nu se afișează liniile de comentarii
  • +noauthority – nu se afișează secțiunea de RR-uri autoritare
  • +noadditional – nu se afișează secțiunea de RR-uri adiționale
  • +nostats – nu se afișează secțiunea de statistici
  • +noanswer – nu se afișează secțiunea de răspuns.

Dacă dorim, putem dezactiva afișarea tuturor secțiunilor (cu opțiunea +noall) și apoi să alegem ce vrem să afișăm. Astfel, cele două comenzi de mai jos sunt echivalente, afișând doar secțiunea de răspuns:

$ dig google.com +nocomments +noquestion +noauthority +noadditional +nostats ; <<>> DiG 9.10.6 <<>> google.com +nocomments +noquestion +noauthority +noadditional +nostats ;; global options: +cmd google.com. 56 IN A 216.58.214.238 $ dig google.com +noall +answer ; <<>> DiG 9.10.6 <<>> google.com +noall +answer ;; global options: +cmd google.com. 43 IN A 216.58.214.23

În mod implicit, dig folosește serverele de nume din fișierul /etc/resolv.conf. Totuși, dacă dorim, putem specifica serverul de nume pe care îl interogăm în felul următor:

$ dig @8.8.8.8 google.com [...] $ dig @ns1.google.com google.com [...]

Dacă se dorește interogarea unui server DNS pentru un număr mai mare de gazde (o interogare de tip bulk), acest lucru se poate face prin adăugarea lor într-un fișier și folosirea opțiunii -f:

$ cat queries.txt google.com facebook.com twitter.com $ dig -f queries.txt +noall +answer google.com. 106 IN A 172.217.19.110 facebook.com. 43 IN A 185.60.218.35 twitter.com. 1052 IN A 104.244.42.193 twitter.com. 1052 IN A 104.244.42.1

Așa cum s-a menționat și mai sus, sistemul DNS este organizat ierarhic, deci o cerere dig parcurge mai multe servere DNS. Acest lucru se poate observa prin intermediul parametrului +trace:

$ dig google.com +noall +answer +trace

Pentru a realiza o căutare inversă (reverse lookup), se folosește opțiunea -x pentru a obține domeniul și numele asociate cu un IP:

$ dig -x 8.8.8.8 +noall +answer ; <<>> DiG 9.10.6 <<>> -x 8.8.8.8 +noall +answer ;; global options: +cmd 8.8.8.8.in-addr.arpa. 17648 IN PTR dns.google.

E-mail

Un mesaj e-mail a fost întotdeauna transmis în format plain-text (text clar). Chiar si prin adăugarea atașamentelor, mesajele de e-mail sunt trimise tot ca mesaje plain-text, prin folosirea unor mecanisme de codificare (uuencode/uudecode, MIME/BASE64).

Un mesaj este format dintr-o secțiune de antete (headers), urmată de o secțiune cu conținutul mesajului. Structura antetelor este descrisă în RFC 822, RFC 1521 și RFC 1806, ele având în general următoarea structură:

  • unul sau mai multe antete Received, care indică ce cale a fost urmată de mesaj de la sursa până la destinație
  • Mime-Version: versiunea MIME (Multipurpose Internet Mail Extensions) folosită, 1.0 in general
  • Content-Type: text/plain pentru mesaje text, multipart/mixed pentru mesaje cu atașamente
  • Subject: subiectul mesajului
  • Date: data și ora când a fost trimis mesajul
  • Message ID: un ID pentru mesaj, folosit pentru identificarea în mod unic a unui mesaj
  • From: numele și adresa de mail a expeditorului
  • To: numele și adresa de mail a destinatarului
  • Cc: carbon copy (alți destinatari)
  • alte antete introduse de clientul de e-mail folosit pentru a trimite mesajul.

Conținutul mesajului este textul propriu-zis, pentru mesajele în text clar fără atașamente. Se poate observa mai jos un exemplu de mesaj:

MIME-Version: 1.0 From: profesor@upb.ro To: student@upb.ro Subject: Tema Content-Type: text/plain Draga student, Fa-ti tema! Cu bine, Profesorul.

Mesajele cu atașamente pot folosi una din următoarele tehnici pentru codificarea acestora:

  • uuencode - la începuturile e-mail-ului, fișierele care se doreau trimise trebuiau convertite în format text și invers prin folosirea utilitarelor numite uuencode/uudecode; și în ziua de azi, unii clienți de mail adaugă atașamentele la sfârșitul mesajelor, codificându-le cu algoritmul folosit de uuencode
  • MIME/Base64 - această tehnologie este cea recomandată pentru trimiterea de mesaje cu atașamente.

Un mesaj cu atașamente codificate MIME arată în felul următor:

MIME-Version: 1.0 From: Student Studentescu <student@upb.ro> To: Profesor PC <profesor@upb.ro> Subject: Re: Tema Content-Type: multipart/mixed; boundary=abc --abc Content-Type: text/plain Atasez tema. Cu bine, Studentul --abc Content-Type: text/plain Content-Disposition: attachment; filename="tema.c" #include <stdio.h> int main() { printf("Aceasta este tema mea\n"); return 0; } --abc

Se observă faptul că părțile care compun mesajul sunt separate între ele printr-un șir de caractere separator (boundary string), specificat ca un parametru pentru antetul Content-Type. Fiecare parte poate avea la rândul ei propriile antete, care conțin în general tipul și numele fișierului din secțiunea respectivă. În cazul în care se trimit atașamente binare, acestea sunt codificate folosind schema numită Base64, descrisă în RFC 1521.

Protocoalele SMTP, POP3 și IMAP

În terminologia folosită de sistemele de e-mail, există trei actori. Aceștia pot fi situați pe trei mașini diferite sau pot co-exista pe aceeași gazdă:

  1. Mail User Agent (MUA) - aplicația folosită de utilizator pentru a citi și trimite mesaje e-mail (clientul de e-mail); el nu primește direct mesaje, acesta fiind rolul Mailbox Server-ului
  2. Mailbox Server - serverul care primește și stochează mesajele (server de e-mail)
  3. Mail Transfer Agent (MTA) - aplicația care primește și retrimite mesajele spre un alt MTA sau spre un Mailbox Server (“router” de e-mail).

Protocolul folosit pentru MTA este SMTP, iar cele mai folosite protocoale pentru interacțiunea cu mailbox-urile sunt POP3 și IMAP.

SMTP

SMTP (Simple Mail Transfer Protocol) este un protocol care se folosește pentru trimiterea mesajelor electronice (de la un client către un server). Acesta se foloseste de portul 25 peste TCP și este descris în RFC 821 și RFC 5321.

Mesajele necesare în SMTP pentru trimiterea unui e-mail sunt următoarele:

1: HELO client.upb.ro 2: MAIL FROM: <profesor@upb.ro> 3: RCPT TO: <student@upb.ro> 4: DATA 5: MIME-Version: 1.0 From: profesor@upb.ro To: student@upb.ro Subject: Tema Content-Type: text/plain Draga student, Fa-ti tema! Cu bine, Profesorul. . 6: QUIT

Se trimite deci întâi o comandă "HELO" cu numele de domeniu sau adresa IP a clientului pentru a iniția sesiunea, apoi o comandă "MAIL FROM" cu adresa sursei, "RCPT TO" pentru destinație, "DATA" pentru date (e-mail-ul în sine) și "QUIT" pentru a se închide sesiunea. Secțiunea de date trebuie neapărat terminată cu secvența de caractere <CR><LF>.<CR><LF> (adică o linie nouă urmată de un punct și apoi de încă o linie nouă).

POP3

POP3 (Post Office Protocol 3) este un protocol utilizat pentru citirea mesajelor electronice (de la un server către un client). Clientul va interoga periodic serverul, va descărca mesajele și le va șterge automat de pe server. Comunicația se realizează folosind portul 110 peste TCP, în felul următor:

1: USERNAME username 2: PASS password 3: LIST 4: RETR 1 5: QUIT

IMAP

IMAP (Internet Message Access Protocol) este un protocol care se folosește pentru citirea mesajelor electronice (de la un server catre un client). Clientul interoghează periodic serverul și poate cere mesaje complete sau doar porțiuni (header, body), și nu va șterge automat mesajele de pe server. Comunicația se realizează prin TCP, folosind portul 143.

1: LOGIN username password 2: LIST "" "*" 3: EXAMINE Inbox 4: FETCH 1 BODY[] 5: LOGOUT

Exerciții

La acest laborator, vom avea 2 seturi de exerciții:

Exerciții DNS

Pornind de la codul disponibil aici, implementați următoatrea cerință:

1. Scrieți un program care să afișeze numele și adresele IP pentru un host. Programul poate primi ca parametru fie numele (caz în care se va afișa adresa), fie adresa IP (caz în care se va afișa numele).

Exemple de apel:

./dns -n google.com ./dns -a 8.8.8.8

Testați-va programul comparând rezultatele cu cele oferite de nslookup sau host.

2. Folosind utilitarul dig, realizați următoarele sarcini:

  • realizați cereri de adresă (A) pentru fiecare gazdă din tabelul de mai jos
  • pentru a afla serverul de mail pentru domeniul din tabel, realizați o cerere de tip MX
Tip  Gazdă  Răspuns  TTL  Prioritate 
Pentru single-v4 există o singură adresă IPv4
single-v4.protocoale.life  127.0.0.1  300   
Pentru single-v4 există o singură adresă IPv6
AAAA  single-v6.protocoale.life  ::1  300   
Pentru single se definesc 2 adrese (una IPv4 si una IPv6)
single.protocoale.life  127.0.0.1  300   
AAAA  single.protocoale.life  ::1  300   
Spațiul dorinel.protocoale.life este delegat către un alt server de nume ce rulează la adresa potato.dfilip.xyz
NS  dorinel.protocoale.life  potato.dfilip.xyz  300   
Pentru multi-v4 există 4 adrese IPv4
multi-v4.protocoale.life  127.1.1.1  300   
multi-v4.protocoale.life  127.2.2.2  300   
multi-v4.protocoale.life  127.3.3.3  300   
multi-v4.protocoale.life  127.4.4.4  300   
Pentru multi-v6 există 4 adrese IPv6
AAAA  multi-v6.protocoale.life  ::1  300   
AAAA  multi-v6.protocoale.life  ::2  300   
AAAA  multi-v6.protocoale.life  ::3  300   
AAAA  multi-v6.protocoale.life  ::4  300   
Pentru multi se definesc 8 adrese (4 de IPv4 și 4 de IPv6)
multi.protocoale.life  127.1.1.1  300   
multi.protocoale.life  127.2.2.2  300   
multi.protocoale.life  127.3.3.3  300   
multi.protocoale.life  127.4.4.4  300   
AAAA  multi.protocoale.life  ::1  300   
AAAA  multi.protocoale.life  ::2  300   
AAAA  multi.protocoale.life  ::3  300   
AAAA  multi.protocoale.life  ::4  300   
Adresele pc→pcom→protocoale definesc un șir de nume canonice care are la capăt o adresă IPv4
CNAME  pc.protocoale.life  pcom.protocoale.life  300   
CNAME  pcom.protocoale.life  protocoale.protocoale.life  300   
protocoale.protocoale.life  127.42.42.42  300   
Emailul este deservit de 3 servere SMTP cu priorități diferite
MX  protocoale.life  alt1.gmail-smtp-in.l.google.com  300  10 
MX  protocoale.life  alt2.gmail-smtp-in.l.google.com  300  20 
MX  protocoale.life  alt3.gmail-smtp-in.l.google.com  300  30 
Tip  Gazdă  Răspuns  TTL  Prioritate

Exerciții E-mail


Suport laborator

Vă oferim aici un cod sursă schelet pentru realizarea unui client de email SMTP scris in C.

Pentru testarea lui, veți folosi un server SMTP acre rulează local. Acesta poate fi creat folosind un utilitar existent în Python, numit smtpd. Rularea acestui utiliar pe portul 25 se face astfel:

sudo python -m smtpd -n -c DebuggingServer 127.0.0.1:25

Atenție! Pentru a putea rula serverul smtpd pe un port mai mic de 1024, trebuie să aveți drepturi de root, deci vom rula cu sudo.


Cerințe

Pornind de la codul disponibil aici, implementați următoatrele cerințe:

  • Implementați un client SMTP peste TCP prin care să trimiteți către serverul smtpd un e-mail care conține niște text și un fișier dat ca parametru sub forma unui atașament de tip text/plain.
  • (Extra) Folosind instrucțiunile de aici, trimiteți un e-mail către asistent prin intermediul serverului SMTP de la Google.

Lectură laborator


De parcurs înainte de laborator:


Primitive criptografice

Pentru a adresa posibilele amenințări ce vizează comunicarea, vom avea nevoie de protocoale de securitate, precum TLS, sau SSH. Acestea au la bază niște primitive criptografice, care ajută la îndeplinirea unor obiective concrete: confidențialitate, integritate, autenticitate etc.

Criptografia în sine este un subiect foarte stufos, și o introducere sumară în criptografie ar alcătui materialul pentru un curs de un singur semestru. Din motive de timp, noi vom parcurge foarte sumar niște concepte de bază, concentrându-ne pe cum le putem folosi, ca utilizatori, pentru a dezvolta protocoale rezistente la atacuri. Pentru cei interesați de cum funcționează aceste lucruri, de teoria din spate, de disciplina practică etc., vă recomandăm:

  • Cursul "Cryptography I" predat de Dan Boneh în cadrul Universității din Stand, California, disponibil pe Coursera.
  • Cartea "Applied Cryptography" a lui Bruce Schneier, disponibilă pe "The Internet Archive".

Principiul lui Kerckhoffs

"Principiul lui Kerckhoffs" este o propoziție simplă care stă la baza criptografiei moderne:


Un sistem criptografic ar trebui să fie sigur, chiar dacă toate amănuntele legate de designul și implementarea lui sunt cunoscute, cu excepția unei cantități mici de date, numite "cheie".


Criptare Simetrică

Criptarea simetrică implică folosirea unei singure chei atât pentru criptarea cât și pentru decriptarea datelor. Astfel, pentru orice mesaj M și pentru orice cheie K, trebuie să se respecte următoarea egalitate:

D(E(M, K), K) = M

Presupunem că Alice și Bob au aceeași cheie secretă K, iar Alice vrea să-i trimită mesajul M lui Bob:

  • Alice folosește algoritmul de encriptie pentru a obține un cifru C = E(M, K)
  • Alice trimite C către Bob. Oricine interceptează acest mesaj, nu poate obține mesajul original M
  • Bob primește C și aplică algoritmul de decriptie pentru a recupera mesajul original M = D(C, K)

Exemple de algoritmi de criptare simetrică:

AES, DES, 3DES, IDEA, Blowfish


Notă

În cazul criptarii simetrice dificultatea provine din necesitatea existenței unui mecanism de securitate pentru distribuirea cheii secrete. În plus, nu este fezabil ca orice pereche de entități care doresc să comunice criptat să împărtășească un secret (e.g. google ar trebui să aibă câte o cheie pentru fiecare client).

Există protocoale de schimbare de chei (key exchange), astfel încât două entități să poată alege împreună o cheie privată peste un canal de comunicare nesigur, astfel încât nimeni altcineva care poate vedea informația transmisă să nu poată determina cheia (și, deci, nici mesajele ulterioare, criptate cu cheia aleasă). Cel mai cunoscut astfel de protocol este DiffieHellman. Acesta este totuși nesigur în cazul atacurilor de tipul Man-in-the-Middle când atacatorul poate intercepta mesaje și produce propriile mesaje.


Criptare Asimetrică

Criptarea asimetrică presupune folosirea unei perechi de chei: una pentru encriptie, cealaltă pentru decriptie. Ambii participanți la trafic au câte o pereche de chei. Cheia de encriptie este o cheie publică, absolut oricine o poate cunoaște și o poate folosi pentru a encripta un mesaj. Cheie de decriptie este o cheie privată (secretă), cunoscută doar de proprietarul ei, acesta putând să o folosească pentru a decripta mesaje encriptate cu cheia să publică.

Astfel, pentru orice pereche de chei (P, S) și orice mesaj M, trebuie să se respecte următoarea egalitate:

  • D(E(M, P), S) = M

În unele sisteme de criptare asimetrică, este posibilă și encriptarea cu cheie secretă, decriptarea cu cheie publică:

  • D(E(M, S), P) = M

Presupunem că Alice vrea să-i trimită lui Bob mesajul M:

  • Alice obține cheia publică PB a lui Bob (e.g. o cere explicit printr-un mesaj)
  • Alice calculează un cifru C = E(M, PB)
  • Alice trimite C către Bob.
  • Bob primește C și aplică algoritmul de decriptie pe cifrul primit și pe cheia să secretă pentru a recupera mesajul original M = D(C, SB)

Deoarece Bob e singură persoană care cunoaște SB, este singurul care poate decripta C. Nici măcar Alice nu mai poate recupera mesajul original din C. Similar, dacă Bob dorește să trimită un răspuns, el trebuie să obțînă cheia publică a lui Alice.

Un algoritm de criptare asimetrică larg utilizat este RSA.


Notă:

Pentru sistemele de criptare asimetrică, există problema: cum putem fi siguri că cheia publică primită chiar aparține cui credem că aparține? (un atacator ar putea folosi un atac Man-in-the-Middle pentru a ne livra propria să cheie).


Rezumate de mesaje

Rezumatul (hash) unui mesaj este un șir de biți de lungime fixă, generat cu ajutorul unei funcții de dispersie neinversabile aplicată mesajului. Funcția de dispersie H trebuie să aibă următoarele proprietăți:

  • Dându-se un mesaj M, este ușor de calculat H(M)
  • Dându-se un rezumat H(M), este greu de calculat M
  • Dându-se un mesaj M, este greu de găsit M0 astfel încât H(M) = H(M0)
  • O schimbare mică în mesaj (chiar și de 1 bit) produce un rezumat foarte diferit

Rezumatele pot fi utilizate pentru a verifică rapid transmisia corectă a unui mesaj (rezumatul este transmis împreună cu mesajul și destinatarul verifică dacă rezumatul primit coincide cu rezumatul recalculat de către el) și pentru realizarea semnăturilor digitale.

Exemple de algoritmi pentru calculul de rezumate: MD5, SHA-1, SHA-2, SHA-3

Semnături digitale

Semnăturile digitale asigură autenticitatea mesajelor, verificarea semnăturilor oferind garanțiile:

  • Autentificare - mesajul provine de la sursă pretinsă și nu a fost falsificat de altcineva
  • Integritate - mesajul nu a fost alterat de altcineva, ci este așa cum a fost scris de sursă
  • Non-repudiere - entitatea care a semnat mesajul nu poate nega ulterior semnarea acestuia

Pentru un sistem foarte simplu de semnătură digitală, să considerăm exemplul în care Alice dorește să-i trimitaa lui Bob un mesaj M, folosindu-se de un sistem de criptare asimetrică și o funcție de hashing.

  • Alice calculează rezumatul mesajului R = H(M)
  • Alice criptează acest rezumat cu cheia secretă obținând un cifru C = E(R, SA)
  • Alice trimite perechea (M, C) către Bob
  • Bob obține cheia publică PĂ a lui Alice
  • Bob primește perechea (M, C)
  • Bob calculează propriul rezumat R = H(M)
  • Bob decripteaza C folosind cheia publică R0 = D(C, PA)
  • Dacă R = R0, Bob are garanția că mesajul a fost semnat de Alice și nimeni nu l-a modificat

Entități de încredere

Pentru că două entități să poată comunica sigur utilizând sistemul de criptare asimetrică, fiecare trebuie să cunoască cheia publică a celeilalte și să nu existe riscul că un intrus să substituie o cheia publică cu propria să cheie publică. Una dintre soluțiile utilizate la ora actuală pentru această problema este certificarea cheilor de către organizații speciale numite autorități de certificare (Certification Authority - CA).

O entitate care dorește un certificat trebuie să se adreseze unei CA, autentificandu-se și furnizând cheia să publică; autoritatea de certificare poate decide să acorde persoanei certificatul, care va conține identitatea și cheia publică a solicitantului. Certificatul este semnat digital de către autoritatea de certificare. Formatul utilizat de obicei pentru certificate este X.509.

Autoritățile de certificare sunt organizate ierarhic, existând o serie de CA-uri "rădăcina" care sunt bine cunoscute, altă serie de CA-uri certificate de CA-urile rădăcina s.a.m.d. În momentul în care este verificat certificatul unei entități se verifică și autoritatea de certificare CA1 care l-a emis, și care are și ea un certificat de la o altă autoritate CA2; apoi se verifică CA2 și așa mai departe până se ajunge la o CA în care se poate avea iıncredere sau la o CA rădăcina (astfel se formează un "lanț de încredere" sau o "cale de certificare"). CA-urile rădăcina au certificate auto-semnate.

Informații despre baza lanțului de încredere sunt incluse în aplicații (mail client, web browser etc.), sau în sistemul de operare, care servește aplicațiile interesate.

Exerciții

Scheletul laboratorului se regaseste la adresa de aici. Funcționalitatea disponibilă este descrisă în README.md. În continuare, vom folosi Mininet pentru a simula o topologie formată din 3 host-uri conectate la un router: sudo python3 topo.py.

Pentru a observa anumite probleme și soluții legate de securitate, vom avea în vedere următorul scenariu:

  • Bob oferă un serviciu de capitalizare a datelor; un client se poate conecta la severul său, să îi trimită pe rând șiruri de date și să primească varianta capitalizată a inputului. (server.c)
  • Alice vrea să folosească serviciul lui Bob. (client.c)
  • Comunicarea dintre cei doi este intermediată de un router compromis, sub controlul unui atacator. (attacker.c)

Găsiți în scheletul de laborator o topologie topo.py care descrie o topologie cu un singur router compromis, situat între Alice și Bob.

Rulați make și rulați topologia (sudo python3 topo.py):

  • rulați ./attacker în terminalul corespunzător routerului (numit "r") (rulați ./attacker 2>/dev/null pentru a elimina outputul de logging)
  • rulați make run_server pe terminalul bob.
  • rulați make run_client pe terminalul alice.
  • din terminalul lui Alice, scrieți mesaje din linia de comandă; acestea vor circula către server, prin routerul compromis; serverul le va prelucra și va transmite înapoi răspunsul.
  • observați cum atacatorul poate vedea tot ce comunică Alice și Bob

1. Comunicare criptată. Pentru ca atacatorul să nu mai poată vedea ce comunică Alice și Bob între ei, modificați atât clientul cât și serverul astfel încât toate datele să fie criptate. Pentru ca Alice și Bob să aibă aceeași cheie, aceasta va fi generată de Alice și transmisă în primul mesaj către Bob. Vim folosi urmatorul API din tea.h. Vom face lucra in client.c si server.c.

/* Cripteaza un mesaj cu cheia k */ uint8_t *encrypt(uint8_t *plaintext, uint32_t *size, const uint32_t *k); /* Decripteaza un mesaj cu cheia k */ uint8_t *decrypt(uint8_t *cipher, uint32_t *size, const uint32_t *k); /* Creeaza o cheie secreta k. Dimensiunea cheii este sizeof(uint32_t) * 4 */ uint32_t *create_key();

2. Interceptare cheie. La exercițiul anterior am criptat toate mesajele dintre Alice și Bob; dar cheia de criptare a fost transmisă prin același canal de comunicare compromis. Modificați codul atacatorului astfel încât să intercepteze cheia transmisă și apoi să decripteze fiecare mesaj. Pentru acest exercitiu vom lucra in attacker.c.

3. Diffie-Hellmann key-exchange. Implementați mecanismul Diffie-Hellmann de schimbare de cheie peste un canal nesecurizat. Astfel Alice și Bob vor ajunge să aibă aceeași cheie de criptare, fără a fi nevoiți să o transfere explicit peste canalul compromis. Vom folosi urmatorul API din dh.h.

/* Calculeaza g^a mod p */ uint32_t mod_pow(uint32_t g, uint32_t a, uint32_t p); /* Generates un secret, secret DH, pe baza numaruilui prim public p. Acesta este valoarea a in cazul lui Alice si b in cazul lui Bob.*/ uint32_t generate_secret(uint32_t p); /* Takes a shared secret and generates an encryption key. The difference between * the two is as follow. Generate_secret returns a for alice. They * compute g^(ab) mod p. This is the shared secret. But you now want to * have a key that we derive from this secret. We need to do this because * the shared secret may have different structure or size. */ uint32_t *derive_key(uint32_t shared_secret);

4. Man-in-the-Middle (MitM). Deși atacatorul nu poate determina cheia schimbată cu DH, el poate altera mesajele din tranzit, încât să transmită valori derivate din propriul secret. Atfel, el poate să facă două instanțe de DH: una cu Alice și una cu Bob; în urma cărora va împărți cu fiecare câte un secret. Astfel, atacatorul poate decripta mesajele de la Alice, le poate citi, și apoi cripta la loc pentru a i le trimite lui Bob; compromițând astfel comunicarea dintre cei doi fără a fi detectat. Modificați atacatorul astfel încât să modifice mesajele în tranzit și să ajungă să facă DH cu Alice și Bob; apoi folosiți cheile obținute pentru a decripta mesajele transmise. Pentru acest exercitiu vom lucra in attacker.c.

Pentru a a preveni MitM în internet, e nevoie ca valorile publice interschimbate să fie însoțite de un certificat semnat de o entitate de încredere.

Lectură laborator


De parcurs înainte de laborator:


Transport Layer Security (TLS)

TLS este un protocol ce implementeaza partea de criptare discutata in laboratorul anterior. O sesiune TLS funcționează peste o conexiune TCP. TLS este responsabil pentru criptarea și autentificarea pachetelor schimbate de protocolul de nivel aplicație, în timp ce TCP asigură livrarea fiabilă a acestui flux de octeți criptat și autentificat. TLS este utilizat de multe protocoale diferite de nivel aplicație, cel mai cunoscut fiind HTTP (HTTP peste TLS este numit HTTPS).

Ilustratia de mai jos prezinta mesajele folosite in protocolul TLS.

OpenSSL

Cea mai populara si raspandita implementare de TLS se gaseste in OpenSSL. OpenSSL este atat o biblioteca ce implementeaza protocolul cat si un executabil openssl -v ce ofera diferite functionalitati precum criptarea sau generarea perechilor de chei publice/private.

sudo apt install libssl-dev

De exemplu, pentru a genera o pereche de chei RSA, putem rula:

openssl genrsa -aes128 -out mykey.key 1024

In schimb, din C avem functii precum BIO_new_connect care face conexiunea TLS cu un server. Peste acest API de C intalnim wrappere in alte limbaje de programare.

bio = BIO_new_connect("hostname:port");

TLS Handshake

Înainte ca un client și un server să poată comunica în siguranță, trebuie stabilite mai întâi mai multe aspecte, inclusiv algoritmul de criptare și cheia care vor fi folosite, algoritmul de Message Autenthification Code (MAC) care va fi folosit, algoritmul care ar trebui folosit pentru schimbul de chei, etc. Acești parametri criptografici trebuie să fie conveniți de către client și server. Acesta este scopul principal al protocolului de strângere de mână TLS (TLS Handshake Protocol). În această sarcină, ne concentrăm pe protocolul de handshake TLS.

Stabilire Handshake

Următorul exemplu de cod inițiază o strângere de mână TLS cu un server TLS (numele serverului trebuie specificat ca prim argument al liniei de comandă). Putem observa cum prima data este deschisa o conexiune TCP peste care urmeaza sa folosim protocolul TLS.

#!/usr/bin/python3 import socket, ssl, sys, pprint # Primim ca argument hostname-ul serverului, de exemplu google.com hostname = sys.argv[1] port = 443 # Create TCP connection sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((hostname, port)) input("After making TCP connection. Press any key to continue ...") # You may need to change this depending on your Linux distro cadir = '/etc/ssl/certs' # Set up the TLS context context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) #context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) # For Ubuntu 16.04 context.load_verify_locations(capath=cadir) context.verify_mode = ssl.CERT_REQUIRED context.check_hostname = True # Add the TLS ssock = context.wrap_socket(sock, server_hostname=hostname, do_handshake_on_connect=False) ssock.do_handshake() # Start the handshake pprint.pprint(ssock.getpeercert()) input("After handshake. Press any key to continue ...") # Close the TLS Connection ssock.shutdown(socket.SHUT_RDWR) ssock.close()

Exercitiu 1. Folosind condul precedent, stabiliti o conexiune catre google.com si raspundeti la urmatoarele intrebari:

  • Care este algoritmul de criptare folosit?
  • Ce reprezinta /etc/ssl/certs?
  • Afisati certificatul serverului.
  • Utilizați Wireshark pentru a captura traficul de rețea în timpul executării programului și explicați observațiile ce se intampla. În special, explicați care pas declanșează handshake-ul TCP, și care pas declanșează handshake-ul TLS. Explicați relația dintre cele doua handshake-uri.

Creearea unui certificat

Pentru a actiona ca un root CA, va trebui sa creeam certificatul. Fisierul ca.key contine cheia privata a CA-ului, pe cand ca.crt este certificatul public.

openssl req -new -x509 -keyout ca.key -out ca.crt

Va trebui sa completati toate campurile. La domain puneti upb-cert-sign.com

Acum putem actiona ca un root CA, suntem gata să semnăm certificate digitale pentru clienții noștri.

Primul nostru client este o companie ce vrea sa administreze domeniul vine-vacanta.com. Pentru ca această companie să obțină un certificat digital de la o Autoritate de Certificare, trebuie să parcurgă trei pași.

  1. Generarea unei perechi de chei publica/privata. Compania trebuie mai întâi să-și creeze propria pereche de chei public/privat. Putem rula următoarea comandă pentru a genera o pereche de chei RSA (atât cheia privată, cât și cea publică). De asemenea, vi se va solicita să furnizați o parolă pentru a cripta cheia privată (folosind algoritmul de criptare AES-128, așa cum este specificat în opțiunea de comandă). Cheile vor fi stocate în fișierul server.key.

    $ openssl genrsa -aes128 -out server.key 2048

    Pentru a vedea continutul in format text a lui server.key puteti folosi comanda openssl rsa -in server.key -text.

  2. Generarea unui Certificate Signing Request (CSR). Odată ce compania are fișierul cheie, ar trebui să genereze o Cerere de Semnare a Certificatului (CSR), care include în esență cheia publică a companiei. CSR va fi trimisă Autorității de Certificare, care va genera un certificat pentru cheie (de obicei, după ce se asigură că informațiile de identitate din CSR corespund cu adevărata identitate a serverului). Vom folosi vine-vacanta.com ca nume comun al cererii de certificat.

    $ openssl req -new -key server.key -out server.csr

    Va trebui sa completati toate campurile.

  3. Generarea certificatelor. Fișierul CSR trebuie să aibă semnătura Autorității de Certificare pentru a forma un certificat. În lumea reală, fișierele CSR sunt de obicei trimise unei Autorități de Certificare de încredere pentru semnătura lor. În acest laborator, vom folosi propria noastră Autoritate de Certificare de încredere pentru a genera certificate. Următoarea comandă transformă cererea de semnătură a certificatului (server.csr) într-un certificat X509 (server.crt), folosind ca.crt și ca.key ale Autorității de Certificare:

    $ sudo openssl ca -in server.csr -out server.crt -cert ca.crt -keyfile ca.key

    Cel mai probabil o sa primiti o eroare legata de lipsa unui fisier index.txt si serial. Va trebui sa le creati astfel (atentie, directorul poate fi diferit)

    sudo touch /etc/pki/CA/index.txt sudo echo "1000" > /etc/pki/CA/serial

    Pe unele versiuni de Ubuntu/Debian, OpenSSL se așteaptă ca aceste fișiere să existe sub directorul demoCA din folderul curent, iar în el să existe subdirectorul newcerts. În acest caz, problema se rezolva cu următoarele comenzi:

    mkdir -p demoCA/newcerts touch demoCA/index.txt echo "1000" > demoCA/serial

    Până la acest moment, ierarhia de fișiere ar trebui să arate ca mai jos:

    dorinel@new-buntu:~/pki$ tree -F ./ ├── ca.crt ├── ca.key ├── demoCA/ │   ├── index.txt │   ├── newcerts/ │   └── serial ├── server.csr └── server.key 3 directories, 6 files

Exercitiu 3. Generati un certificat pentru domeniul vine-vacanta.com. Vom adauga urmatoarea intrare in /etc/hosts pentru a il face pe Linux sa faca legatura dintre vine-vacanta.com si 127.0.0.1.

Pentru a putea folosi certificatul in directorul curent, vom rula:

openssl x509 -in server.crt -noout -subject_hash
127.0.0.1 vine-vacanta.com

Server HTTPS

In acest exercitiu vom dezvolta un server de HTTPS. Pentru a realiza acest lucru va trebui sa avem la dispozitie certificatele create la exercitiul anterior pe care sa le trimitem clientilor.

#!/usr/bin/python3 import socket import ssl html = """HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<!DOCTYPE html><html><body><h1>Hello, world!</h1></body></html>""" SERVER_CERT = './server.crt' SERVER_PRIVATE = './server.key' context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) #context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) # Ubuntu 16.04 context.load_cert_chain(SERVER_CERT, SERVER_PRIVATE) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) sock.bind(('0.0.0.0', 4433)) sock.listen(5) while True: newsock, fromaddr = sock.accept() ssock = context.wrap_socket(newsock, server_side=True) data = ssock.recv(1024) # Read data over TLS ssock.sendall(html.encode('utf-8')) # Send data over TLS ssock.shutdown(socket.SHUT_RDWR) # Close the TLS connection ssock.close()

Exercitiu 4. Creeati un server HTTPS care reprezinta pagina principala a site-ului vine-vacanta.com. Vom folosi wget sau curl pentru a testa implementarea.

sudo curl --cacert ca.crt https://vine-vacanta.com:4433

Pentru a nu rula serverul cu sudo, nu vom folosi protul 443 folosit implicit de HTTPS. Din cod observam ca folosim portul 4433 pentru server, asa ca o sa rulam cu vine-vacanta.com:4433

Exercitiu 5. În aceast exercitiu, vom testa implementarea serverului nostru de HTTPS folosind un browser, cum ar fi Firefox. În primul rând, vom accesa vine-vacanta.com din browserul. Ce se inampla?

Pentru ca un browser sa poata stabili o conexiune cu serverul HTTPS, acesta trebuie să verifice certificatul. Trebuie să aiba certificatul CA-ului care a emis certificatul serverului. In cazul nostru, CA authority este creat în de noi si browserul nu o are în lista sa de certificate de încredere. Trebuie să adăugăm manual certificatul CA-ului nostru în lista de certificate ale browserului. Pentru browserul Firefox, faceți clic pe următoarea secvență de meniu:

Edit -> Preference -> Privacy & Security -> View Certificates

Veți vedea o listă de certificate care sunt deja acceptate de Firefox. De aici, putem "importa" propriul nostru certificat. Vă rugăm să importați ca.crt și să selectați următoarea opțiune: "Trust this CA to identify web sites". Veți vedea că certificatul Autorității noastre de Certificare se află acum în lista Firefox a certificatelor acceptate.

Exercitiu 6. Conectativa in aceasi retea cu mai multi colegi. Unul dintre voi va rula serverul, iar ceilalti il vor putea accesa din browser.

Va trebui sa modificati adresa IP a lui vine-vacanta.com in functie de cine ruleaza serverul.